From 470c2969793c2ce2eb57e77c48fb7295f4cc5f45 Mon Sep 17 00:00:00 2001 From: Aymen Date: Mon, 23 Feb 2026 15:13:33 +0100 Subject: [PATCH 1/2] feat: cleanup --- app/cli/run.py | 2 +- app/runtime/agent/__init__.py | 15 +- app/runtime/agent/agent.py | 82 +- app/runtime/agent/hitl.py | 486 +++---- app/runtime/agent/hitl_channels.py | 275 ++++ app/runtime/agent/prompt.py | 4 +- app/runtime/agent/tools.py | 202 --- app/runtime/agent/tools/__init__.py | 42 + app/runtime/agent/tools/cards.py | 114 ++ app/runtime/agent/tools/memory.py | 51 + app/runtime/agent/tools/scheduler.py | 94 ++ app/runtime/agent/tools/voice.py | 67 + app/runtime/env_cli.py | 4 +- app/runtime/media/__init__.py | 3 +- app/runtime/media/incoming.py | 43 +- app/runtime/media/outgoing.py | 43 + app/runtime/messaging/__init__.py | 7 +- app/runtime/messaging/cards.py | 108 +- app/runtime/messaging/commands.py | 582 --------- app/runtime/messaging/commands/__init__.py | 24 + app/runtime/messaging/commands/_dispatcher.py | 199 +++ app/runtime/messaging/commands/agent.py | 191 +++ app/runtime/messaging/commands/session.py | 130 ++ app/runtime/messaging/commands/system.py | 206 +++ app/runtime/messaging/message_processor.py | 10 +- app/runtime/{ => messaging}/proactive_loop.py | 20 +- app/runtime/realtime/__init__.py | 2 + app/runtime/realtime/auth.py | 12 + app/runtime/realtime/caller.py | 47 +- app/runtime/realtime/middleware.py | 33 +- app/runtime/realtime/prompt.py | 8 +- app/runtime/realtime/tools.py | 9 +- app/runtime/registries/__init__.py | 5 + app/runtime/registries/catalog.py | 287 +++++ app/runtime/registries/skills.py | 243 +--- app/runtime/sandbox/__init__.py | 20 + .../{sandbox.py => sandbox/executor.py} | 272 +--- app/runtime/sandbox/helpers.py | 53 + app/runtime/sandbox/interceptor.py | 133 ++ app/runtime/scheduler/__init__.py | 25 + .../{scheduler.py => scheduler/engine.py} | 37 +- app/runtime/server/__init__.py | 3 +- app/runtime/server/app.py | 1121 ++++------------- app/runtime/server/app_routes.py | 230 ++++ app/runtime/server/app_static.py | 100 ++ app/runtime/server/chat.py | 226 +++- app/runtime/server/lifecycle.py | 371 ++++++ app/runtime/server/middleware.py | 120 ++ app/runtime/server/routes/__init__.py | 14 + app/runtime/server/routes/_helpers.py | 24 + .../server/routes/content_safety_routes.py | 6 +- app/runtime/server/routes/env_routes.py | 10 +- .../server/routes/foundry_iq_routes.py | 15 +- .../server/routes/guardrails_routes.py | 220 +--- .../server/routes/guardrails_routes_meta.py | 135 ++ app/runtime/server/routes/identity_routes.py | 107 +- app/runtime/server/routes/mcp_routes.py | 4 - .../server/routes/monitoring_routes.py | 17 +- app/runtime/server/routes/network_audit.py | 299 +++++ app/runtime/server/routes/network_routes.py | 423 +------ app/runtime/server/routes/network_topology.py | 162 +++ app/runtime/server/routes/sandbox_routes.py | 17 +- .../routes/security_preflight_routes.py | 2 +- app/runtime/server/runtime_proxy.py | 5 +- app/runtime/server/setup/__init__.py | 19 + app/runtime/server/setup/_helpers.py | 15 + .../server/{setup.py => setup/_routes.py} | 321 +---- app/runtime/server/setup/azure.py | 81 ++ app/runtime/server/setup/deploy.py | 241 ++++ .../preflight.py} | 10 +- .../prerequisites.py} | 14 +- app/runtime/server/setup/voice.py | 470 +++++++ app/runtime/server/setup/voice_provision.py | 237 ++++ app/runtime/server/setup_voice.py | 234 +--- app/runtime/server/smoke_test.py | 2 +- app/runtime/server/tunnel_status.py | 55 +- app/runtime/server/wiring.py | 265 ++++ app/runtime/services/__init__.py | 50 +- app/runtime/services/aca_deployer.py | 843 ------------- app/runtime/services/cloud/__init__.py | 9 + app/runtime/services/cloud/_azure_rbac.py | 43 + app/runtime/services/{ => cloud}/azure.py | 13 +- app/runtime/services/{ => cloud}/github.py | 5 +- .../services/{ => cloud}/runtime_identity.py | 60 +- app/runtime/services/deployment/__init__.py | 9 + .../services/deployment/aca_deployer.py | 455 +++++++ .../services/deployment/aca_provision.py | 433 +++++++ .../services/{ => deployment}/deployer.py | 6 +- .../services/{ => deployment}/provisioner.py | 10 +- app/runtime/services/keyvault.py | 10 + app/runtime/services/otel.py | 5 + app/runtime/services/resource_tracker.py | 2 +- app/runtime/services/security/__init__.py | 9 + .../{ => security}/misconfig_checker.py | 2 +- .../services/security/preflight_identity.py | 240 ++++ .../services/security/preflight_rbac.py | 276 ++++ .../services/security/preflight_secrets.py | 301 +++++ .../services/{ => security}/prompt_shield.py | 2 - .../services/security/security_preflight.py | 154 +++ app/runtime/services/security_preflight.py | 918 -------------- app/runtime/state/__init__.py | 4 + app/runtime/state/_base.py | 127 ++ app/runtime/state/_json_store.py | 2 +- app/runtime/state/deploy_state.py | 19 +- app/runtime/state/foundry_iq_config.py | 72 +- app/runtime/state/guardrails/__init__.py | 49 + app/runtime/state/guardrails/bulk.py | 123 ++ app/runtime/state/guardrails/config.py | 505 ++++++++ app/runtime/state/guardrails/models.py | 52 + app/runtime/state/guardrails/presets.py | 269 ++++ app/runtime/state/guardrails/risk.py | 118 ++ app/runtime/state/guardrails_config.py | 567 +-------- app/runtime/state/infra_config.py | 119 +- app/runtime/state/mcp_config.py | 4 +- app/runtime/state/memory.py | 15 +- app/runtime/state/monitoring_config.py | 47 +- app/runtime/state/plugin_config.py | 34 +- app/runtime/state/proactive.py | 2 +- app/runtime/state/profile.py | 8 +- app/runtime/state/sandbox_config.py | 51 +- app/runtime/state/tool_activity_models.py | 91 ++ app/runtime/state/tool_activity_store.py | 132 +- app/runtime/tests/test_agent_tools.py | 6 +- app/runtime/tests/test_azure_cli.py | 12 +- .../tests/test_content_safety_routes.py | 4 +- app/runtime/tests/test_extract_media.py | 5 +- .../test_guardrails_policy_validation.py | 23 +- app/runtime/tests/test_guardrails_presets.py | 25 +- app/runtime/tests/test_hitl.py | 70 +- app/runtime/tests/test_identity_routes.py | 104 +- app/runtime/tests/test_incoming_media.py | 6 +- app/runtime/tests/test_misconfig_checker.py | 2 +- app/runtime/tests/test_prerequisites.py | 2 +- app/runtime/tests/test_prompt_shield.py | 2 +- app/runtime/tests/test_provisioner.py | 8 +- app/runtime/tests/test_realtime_tools.py | 6 +- app/runtime/tests/test_sandbox_executor.py | 27 +- app/runtime/tests/test_spotlight.py | 10 +- app/runtime/tests/test_tunnel_restriction.py | 2 +- docs/content/architecture/state.md | 55 +- docs/content/configuration/_index.md | 7 +- docs/content/features/monitoring.md | 13 + .../screenshots/mafpreview-appinsights.png | Bin 0 -> 731276 bytes 143 files changed, 9722 insertions(+), 6717 deletions(-) create mode 100644 app/runtime/agent/hitl_channels.py delete mode 100644 app/runtime/agent/tools.py create mode 100644 app/runtime/agent/tools/__init__.py create mode 100644 app/runtime/agent/tools/cards.py create mode 100644 app/runtime/agent/tools/memory.py create mode 100644 app/runtime/agent/tools/scheduler.py create mode 100644 app/runtime/agent/tools/voice.py delete mode 100644 app/runtime/messaging/commands.py create mode 100644 app/runtime/messaging/commands/__init__.py create mode 100644 app/runtime/messaging/commands/_dispatcher.py create mode 100644 app/runtime/messaging/commands/agent.py create mode 100644 app/runtime/messaging/commands/session.py create mode 100644 app/runtime/messaging/commands/system.py rename app/runtime/{ => messaging}/proactive_loop.py (96%) create mode 100644 app/runtime/registries/catalog.py create mode 100644 app/runtime/sandbox/__init__.py rename app/runtime/{sandbox.py => sandbox/executor.py} (67%) create mode 100644 app/runtime/sandbox/helpers.py create mode 100644 app/runtime/sandbox/interceptor.py create mode 100644 app/runtime/scheduler/__init__.py rename app/runtime/{scheduler.py => scheduler/engine.py} (93%) create mode 100644 app/runtime/server/app_routes.py create mode 100644 app/runtime/server/app_static.py create mode 100644 app/runtime/server/lifecycle.py create mode 100644 app/runtime/server/middleware.py create mode 100644 app/runtime/server/routes/_helpers.py create mode 100644 app/runtime/server/routes/guardrails_routes_meta.py create mode 100644 app/runtime/server/routes/network_audit.py create mode 100644 app/runtime/server/routes/network_topology.py create mode 100644 app/runtime/server/setup/__init__.py create mode 100644 app/runtime/server/setup/_helpers.py rename app/runtime/server/{setup.py => setup/_routes.py} (50%) create mode 100644 app/runtime/server/setup/azure.py create mode 100644 app/runtime/server/setup/deploy.py rename app/runtime/server/{setup_preflight.py => setup/preflight.py} (98%) rename app/runtime/server/{setup_prerequisites.py => setup/prerequisites.py} (97%) create mode 100644 app/runtime/server/setup/voice.py create mode 100644 app/runtime/server/setup/voice_provision.py create mode 100644 app/runtime/server/wiring.py delete mode 100644 app/runtime/services/aca_deployer.py create mode 100644 app/runtime/services/cloud/__init__.py create mode 100644 app/runtime/services/cloud/_azure_rbac.py rename app/runtime/services/{ => cloud}/azure.py (98%) rename app/runtime/services/{ => cloud}/github.py (97%) rename app/runtime/services/{ => cloud}/runtime_identity.py (90%) create mode 100644 app/runtime/services/deployment/__init__.py create mode 100644 app/runtime/services/deployment/aca_deployer.py create mode 100644 app/runtime/services/deployment/aca_provision.py rename app/runtime/services/{ => deployment}/deployer.py (99%) rename app/runtime/services/{ => deployment}/provisioner.py (97%) create mode 100644 app/runtime/services/security/__init__.py rename app/runtime/services/{ => security}/misconfig_checker.py (99%) create mode 100644 app/runtime/services/security/preflight_identity.py create mode 100644 app/runtime/services/security/preflight_rbac.py create mode 100644 app/runtime/services/security/preflight_secrets.py rename app/runtime/services/{ => security}/prompt_shield.py (99%) create mode 100644 app/runtime/services/security/security_preflight.py delete mode 100644 app/runtime/services/security_preflight.py create mode 100644 app/runtime/state/_base.py create mode 100644 app/runtime/state/guardrails/__init__.py create mode 100644 app/runtime/state/guardrails/bulk.py create mode 100644 app/runtime/state/guardrails/config.py create mode 100644 app/runtime/state/guardrails/models.py create mode 100644 app/runtime/state/guardrails/presets.py create mode 100644 app/runtime/state/guardrails/risk.py create mode 100644 app/runtime/state/tool_activity_models.py create mode 100644 docs/themes/polyclaw/static/screenshots/mafpreview-appinsights.png diff --git a/app/cli/run.py b/app/cli/run.py index c7bf948..f03db3c 100644 --- a/app/cli/run.py +++ b/app/cli/run.py @@ -28,7 +28,7 @@ from app.runtime.agent.agent import Agent from app.runtime.config.settings import cfg -from app.runtime.state.guardrails_config import GuardrailsConfigStore +from app.runtime.state.guardrails import GuardrailsConfigStore from app.runtime.state.memory import get_memory from app.runtime.state.sandbox_config import SandboxConfigStore from app.runtime.state.session_store import SessionStore diff --git a/app/runtime/agent/__init__.py b/app/runtime/agent/__init__.py index 67a213d..069c153 100644 --- a/app/runtime/agent/__init__.py +++ b/app/runtime/agent/__init__.py @@ -1,3 +1,14 @@ -"""Core Copilot SDK integration -- agent, sessions, tools, and prompts.""" +"""Core Copilot SDK integration -- agent, sessions, tools, and prompts. -__all__ = ["Agent", "auto_approve", "run_one_shot"] +Public submodules (import directly): + +- ``agent.agent`` -- ``Agent``, ``MAX_START_RETRIES`` +- ``agent.aitl`` -- ``AitlReviewer`` +- ``agent.event_handler`` -- ``EventHandler`` +- ``agent.hitl`` -- ``HitlInterceptor`` +- ``agent.one_shot`` -- ``run_one_shot``, ``auto_approve`` +- ``agent.phone_verify`` -- ``PhoneVerifier`` +- ``agent.policy_bridge`` -- ``build_engine``, ``config_to_yaml``, ... +- ``agent.prompt`` -- ``build_system_prompt``, ``load_soul``, ``TEMPLATES_DIR`` +- ``agent.tools`` -- ``get_all_tools``, ``ALL_TOOLS``, tool functions +""" diff --git a/app/runtime/agent/agent.py b/app/runtime/agent/agent.py index a51f7e9..0b42cce 100644 --- a/app/runtime/agent/agent.py +++ b/app/runtime/agent/agent.py @@ -14,7 +14,7 @@ from ..config.settings import cfg from ..sandbox import SandboxExecutor, SandboxToolInterceptor from ..services.otel import invoke_agent_span, set_span_attribute -from ..state.guardrails_config import GuardrailsConfigStore +from ..state.guardrails import GuardrailsConfigStore from ..state.mcp_config import McpConfigStore from .event_handler import EventHandler from .hitl import HitlInterceptor @@ -352,26 +352,20 @@ async def list_models(self) -> list[dict]: logger.warning("Failed to list models: %s", exc) return [] - def _build_session_config(self) -> dict[str, Any]: - sandbox_active = self._interceptor and self._sandbox and self._sandbox.enabled - # Always register the HITL hook when an interceptor exists so that - # guardrails config changes (enable/disable) take effect without - # requiring a session restart. The hook itself checks hitl_enabled - # at call time via resolve_action() which returns "allow" when off. - hitl_available = self._hitl is not None - - logger.info( - "[agent.config] building session config: " - "sandbox_active=%s hitl_available=%s hitl_enabled=%s", - sandbox_active, hitl_available, - self._guardrails.hitl_enabled if self._guardrails else "(no store)", + def _build_hooks(self) -> dict[str, Any]: + """Compose pre/post-tool-use hooks from active interceptors.""" + sandbox_active = ( + self._interceptor and self._sandbox and self._sandbox.enabled ) + hitl_available = self._hitl is not None if sandbox_active and hitl_available: hitl = self._hitl sandbox = self._interceptor - async def chained_pre_tool_use(input_data: dict, invocation: Any) -> dict: + async def chained_pre_tool_use( + input_data: dict, invocation: Any, + ) -> dict: logger.info( "[agent.hook] chained_pre_tool_use called: tool=%s", input_data.get("toolName", "?"), @@ -380,7 +374,9 @@ async def chained_pre_tool_use(input_data: dict, invocation: Any) -> dict: if result.get("permissionDecision") != "allow": logger.info("[agent.hook] hitl denied, skipping sandbox") return result - logger.info("[agent.hook] hitl allowed, proceeding to sandbox") + logger.info( + "[agent.hook] hitl allowed, proceeding to sandbox", + ) return await sandbox.on_pre_tool_use(input_data, invocation) hooks: dict[str, Any] = { @@ -399,30 +395,66 @@ async def chained_pre_tool_use(input_data: dict, invocation: Any) -> dict: logger.info("[agent.config] hooks: sandbox only") else: hooks = {"on_pre_tool_use": auto_approve} - logger.info("[agent.config] hooks: auto_approve (no hitl, no sandbox)") + logger.info( + "[agent.config] hooks: auto_approve (no hitl, no sandbox)", + ) + + return hooks + + def _build_session_config(self) -> dict[str, Any]: + """Assemble the full session configuration for the Copilot SDK.""" + sandbox_active = ( + self._interceptor and self._sandbox and self._sandbox.enabled + ) + logger.info( + "[agent.config] building session config: " + "sandbox_active=%s hitl_available=%s hitl_enabled=%s", + sandbox_active, self._hitl is not None, + self._guardrails.hitl_enabled if self._guardrails else "(no store)", + ) session_cfg: dict[str, Any] = { "model": cfg.copilot_model, "streaming": True, "tools": get_all_tools(), - "system_message": {"mode": "replace", "content": build_system_prompt()}, - "hooks": hooks, - "skill_directories": [str(cfg.builtin_skills_dir), str(cfg.user_skills_dir)], + "system_message": { + "mode": "replace", + "content": build_system_prompt(), + }, + "hooks": self._build_hooks(), + "skill_directories": [ + str(cfg.builtin_skills_dir), + str(cfg.user_skills_dir), + ], } if sandbox_active: - session_cfg["excluded_tools"] = ["create", "view", "edit", "grep", "glob"] + session_cfg["excluded_tools"] = [ + "create", "view", "edit", "grep", "glob", + ] try: - session_cfg["mcp_servers"] = McpConfigStore().get_enabled_servers() + session_cfg["mcp_servers"] = ( + McpConfigStore().get_enabled_servers() + ) except Exception: - logger.warning("Failed to load MCP config, using defaults", exc_info=True) + logger.warning( + "Failed to load MCP config, using defaults", + exc_info=True, + ) session_cfg["mcp_servers"] = { "playwright": { "type": "local", "command": "npx", - "args": ["-y", "@playwright/mcp@latest", "--browser", "chromium", "--headless", "--isolated"], - "env": {"PLAYWRIGHT_CHROMIUM_ARGS": "--no-sandbox --disable-setuid-sandbox"}, + "args": [ + "-y", "@playwright/mcp@latest", + "--browser", "chromium", + "--headless", "--isolated", + ], + "env": { + "PLAYWRIGHT_CHROMIUM_ARGS": + "--no-sandbox --disable-setuid-sandbox", + }, "tools": ["*"], }, } diff --git a/app/runtime/agent/hitl.py b/app/runtime/agent/hitl.py index 6cf3bad..4691789 100644 --- a/app/runtime/agent/hitl.py +++ b/app/runtime/agent/hitl.py @@ -8,56 +8,87 @@ from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Any -from ..state.guardrails_config import GuardrailsConfigStore -from ..util.async_helpers import run_sync +from ..state.guardrails import GuardrailsConfigStore +from .hitl_channels import ( + apply_aitl_review, + apply_filter_check, + ask_bot_approval, + ask_chat_approval, + ask_phone_approval, +) if TYPE_CHECKING: - from ..services.prompt_shield import PromptShieldService + from ..services.security.prompt_shield import PromptShieldService from ..state.tool_activity_store import ToolActivityStore from .aitl import AitlReviewer from .phone_verify import PhoneVerifier logger = logging.getLogger(__name__) -_APPROVAL_TIMEOUT = 300.0 - _ALWAYS_APPROVED_TOOLS: frozenset[str] = frozenset({"report_intent"}) +_ALLOW: dict[str, str] = {"permissionDecision": "allow"} +_DENY: dict[str, str] = {"permissionDecision": "deny"} + class HitlInterceptor: + """Human-in-the-loop tool approval interceptor. + + Per-turn state (emit, model, session context) is bound via + :meth:`bind_turn` and released via :meth:`unbind_turn`. Persistent + wiring (phone verifier, AITL reviewer, prompt shield) is set once + during application startup. + """ def __init__(self, guardrails: GuardrailsConfigStore) -> None: self._guardrails = guardrails + + # -- per-turn state (bound/unbound each agent turn) ---------------- self._emit: Callable[[str, dict[str, Any]], None] | None = None self._bot_reply_fn: Callable[[str], Awaitable[None]] | None = None self._execution_context: str = "" self._model: str = "" self._session_id: str = "" + self._tool_activity: ToolActivityStore | None = None + + # -- persistent state ---------------------------------------------- self._pending: dict[str, asyncio.Future[bool]] = {} self._phone_verifier: PhoneVerifier | None = None self._aitl_reviewer: AitlReviewer | None = None self._prompt_shield: PromptShieldService | None = None - self._tool_activity: ToolActivityStore | None = None self._resolved_strategies: dict[str, list[str]] = {} self._last_shield_result: dict[str, Any] | None = None - def set_emit(self, emit: Callable[[str, dict[str, Any]], None]) -> None: + # -- per-turn lifecycle ------------------------------------------------ + + def bind_turn( + self, + *, + emit: Callable[[str, dict[str, Any]], None] | None = None, + bot_reply_fn: Callable[[str], Awaitable[None]] | None = None, + execution_context: str = "", + model: str = "", + session_id: str = "", + tool_activity: ToolActivityStore | None = None, + ) -> None: + """Bind per-turn state before an agent send.""" self._emit = emit + self._bot_reply_fn = bot_reply_fn + self._execution_context = execution_context + self._model = model + self._session_id = session_id + self._tool_activity = tool_activity - def clear_emit(self) -> None: + def unbind_turn(self) -> None: + """Clear per-turn state after an agent send completes.""" self._emit = None - - def set_bot_reply_fn(self, fn: Callable[[str], Awaitable[None]]) -> None: - self._bot_reply_fn = fn - - def clear_bot_reply_fn(self) -> None: self._bot_reply_fn = None + self._execution_context = "" + self._model = "" + self._session_id = "" + self._tool_activity = None - def set_execution_context(self, context: str) -> None: - self._execution_context = context - - def set_model(self, model: str) -> None: - self._model = model + # -- persistent wiring ------------------------------------------------- def set_phone_verifier(self, verifier: PhoneVerifier) -> None: self._phone_verifier = verifier @@ -68,12 +99,6 @@ def set_aitl_reviewer(self, reviewer: AitlReviewer) -> None: def set_prompt_shield(self, shield: PromptShieldService) -> None: self._prompt_shield = shield - def set_tool_activity(self, store: ToolActivityStore) -> None: - self._tool_activity = store - - def set_session_id(self, session_id: str) -> None: - self._session_id = session_id - def pop_resolved_strategy(self, tool_name: str) -> str: queue = self._resolved_strategies.get(tool_name) if not queue: @@ -136,6 +161,7 @@ async def on_pre_tool_use(self, input_data: dict, invocation: Any) -> dict: return result async def _evaluate_tool(self, input_data: dict, tool_name: str) -> dict: + """Evaluate a tool invocation against the guardrails policy.""" self._last_shield_result = None call_id = input_data.get("toolCallId") or str(uuid.uuid4())[:8] @@ -151,10 +177,6 @@ async def _evaluate_tool(self, input_data: dict, tool_name: str) -> dict: self._model, mcp_server or "(none)", self._guardrails.hitl_enabled, ) - logger.info( - "[hitl.hook] input_data keys=%s", - list(input_data.keys()), - ) strategy = self._guardrails.resolve_action( tool_name, @@ -167,22 +189,20 @@ async def _evaluate_tool(self, input_data: dict, tool_name: str) -> dict: strategy, tool_name, ) + # Terminal strategies if strategy == "allow": logger.info("[hitl.hook] ALLOW tool=%s call_id=%s", tool_name, call_id) - return {"permissionDecision": "allow"} + return _ALLOW if strategy == "deny": - logger.info("[hitl.hook] DENY tool=%s call_id=%s", tool_name, call_id) - self._resolved_strategies.setdefault(tool_name, []).append("deny") - if self._emit: - self._emit("tool_denied", { - "call_id": call_id, - "tool": tool_name, - "reason": "Denied by guardrail rule", - }) - return {"permissionDecision": "deny"} - - if self._prompt_shield and self._prompt_shield.configured and strategy != "filter": + return self._make_deny(call_id, tool_name) + + # Pre-filter: run Prompt Shield before non-filter strategies + if ( + self._prompt_shield + and self._prompt_shield.configured + and strategy != "filter" + ): shield_result = await self._apply_filter(call_id, tool_name, args_str) if shield_result is not None: logger.info( @@ -192,53 +212,117 @@ async def _evaluate_tool(self, input_data: dict, tool_name: str) -> dict: ) return shield_result - if strategy == "aitl": - self._resolved_strategies.setdefault(tool_name, []).append("aitl") - if self._aitl_reviewer: - result = await self._apply_aitl(call_id, tool_name, args_str) - if result is not None: - return result - logger.warning( - "[hitl] AITL requested but unavailable, falling back to interactive: tool=%s", - tool_name, - ) + # Strategy-specific handler + result = await self._dispatch_strategy( + strategy, call_id, tool_name, args_str, + ) + if result is not None: + return result - if strategy == "filter": - self._resolved_strategies.setdefault(tool_name, []).append("filter") - if self._prompt_shield: - result = await self._apply_filter(call_id, tool_name, args_str) - if result is not None: - return result - logger.info( - "[hitl.hook] shield passed, ALLOW tool=%s call_id=%s", - tool_name, call_id, - ) - return {"permissionDecision": "allow"} - logger.warning( - "[hitl] no prompt shield available, allowing tool=%s (Content Safety not deployed)", - tool_name, - ) - self._last_shield_result = { - "result": "skipped", - "detail": "Content Safety not deployed", - "elapsed_ms": None, - } - return {"permissionDecision": "allow"} + # Fallback: interactive approval + return await self._route_interactive( + call_id, tool_name, args_str, mcp_server, + ) + def _make_deny(self, call_id: str, tool_name: str) -> dict: + """Build a deny response and emit an event.""" + logger.info("[hitl.hook] DENY tool=%s call_id=%s", tool_name, call_id) + self._resolved_strategies.setdefault(tool_name, []).append("deny") + if self._emit: + self._emit("tool_denied", { + "call_id": call_id, + "tool": tool_name, + "reason": "Denied by guardrail rule", + }) + return dict(_DENY) + + async def _dispatch_strategy( + self, + strategy: str, + call_id: str, + tool_name: str, + args_str: str, + ) -> dict | None: + """Delegate to a strategy-specific handler. + + Returns a decision dict, or ``None`` to fall through to + interactive approval. + """ + if strategy == "aitl": + return await self._handle_aitl(call_id, tool_name, args_str) + if strategy == "filter": + return await self._handle_filter(call_id, tool_name, args_str) if strategy == "pitl": - self._resolved_strategies.setdefault(tool_name, []).append("pitl") - if self._phone_verifier: - logger.info("[hitl.hook] PITL routing to phone: tool=%s", tool_name) - return await self._ask_phone(call_id, tool_name, args_str) - logger.warning( - "[hitl] PITL requested but phone verifier unavailable, " - "falling back to chat: tool=%s", tool_name, + return await self._handle_pitl(call_id, tool_name, args_str) + return None + + async def _handle_aitl( + self, call_id: str, tool_name: str, args_str: str, + ) -> dict | None: + """AI-in-the-loop review.""" + self._resolved_strategies.setdefault(tool_name, []).append("aitl") + if self._aitl_reviewer: + return await self._apply_aitl(call_id, tool_name, args_str) + logger.warning( + "[hitl] AITL requested but unavailable, " + "falling back to interactive: tool=%s", + tool_name, + ) + return None + + async def _handle_filter( + self, call_id: str, tool_name: str, args_str: str, + ) -> dict | None: + """Content-safety filter.""" + self._resolved_strategies.setdefault(tool_name, []).append("filter") + if self._prompt_shield: + result = await self._apply_filter(call_id, tool_name, args_str) + if result is not None: + return result + logger.info( + "[hitl.hook] shield passed, ALLOW tool=%s call_id=%s", + tool_name, call_id, ) + return dict(_ALLOW) + logger.warning( + "[hitl] no prompt shield available, allowing tool=%s " + "(Content Safety not deployed)", + tool_name, + ) + self._last_shield_result = { + "result": "skipped", + "detail": "Content Safety not deployed", + "elapsed_ms": None, + } + return dict(_ALLOW) + + async def _handle_pitl( + self, call_id: str, tool_name: str, args_str: str, + ) -> dict | None: + """Phone-in-the-loop verification.""" + self._resolved_strategies.setdefault(tool_name, []).append("pitl") + if self._phone_verifier: + logger.info("[hitl.hook] PITL routing to phone: tool=%s", tool_name) + return await self._ask_phone(call_id, tool_name, args_str) + logger.warning( + "[hitl] PITL requested but phone verifier unavailable, " + "falling back to chat: tool=%s", + tool_name, + ) + return None + async def _route_interactive( + self, + call_id: str, + tool_name: str, + args_str: str, + mcp_server: str, + ) -> dict: + """Route to the best available interactive approval channel.""" logger.info( - "[hitl.hook] interactive approval needed: tool=%s strategy=%s " + "[hitl.hook] interactive approval needed: tool=%s " "has_emit=%s has_bot_reply=%s has_phone=%s", - tool_name, strategy, + tool_name, self._emit is not None, self._bot_reply_fn is not None, self._phone_verifier is not None, @@ -251,26 +335,32 @@ async def _evaluate_tool(self, input_data: dict, tool_name: str) -> dict: ) if channel == "phone" and self._phone_verifier: - logger.info("[hitl.hook] routing to phone channel: tool=%s", tool_name) + logger.info( + "[hitl.hook] routing to phone channel: tool=%s", tool_name, + ) self._resolved_strategies.setdefault(tool_name, []).append("pitl") return await self._ask_phone(call_id, tool_name, args_str) if self._bot_reply_fn: - logger.info("[hitl.hook] routing to bot channel: tool=%s", tool_name) + logger.info( + "[hitl.hook] routing to bot channel: tool=%s", tool_name, + ) self._resolved_strategies.setdefault(tool_name, []).append("hitl") return await self._ask_bot_channel(call_id, tool_name, args_str) if self._emit: - logger.info("[hitl.hook] routing to web chat: tool=%s", tool_name) + logger.info( + "[hitl.hook] routing to web chat: tool=%s", tool_name, + ) self._resolved_strategies.setdefault(tool_name, []).append("hitl") return await self._ask_chat(call_id, tool_name, args_str) logger.error( - "[hitl.hook] NO APPROVAL CHANNEL available (no bot_reply_fn, " - "no emit) -- denying tool=%s call_id=%s to avoid silent hang", + "[hitl.hook] NO APPROVAL CHANNEL available -- " + "denying tool=%s call_id=%s to avoid silent hang", tool_name, call_id, ) - return {"permissionDecision": "deny"} + return dict(_DENY) def resolve_approval(self, call_id: str, approved: bool) -> bool: future = self._pending.get(call_id) @@ -299,211 +389,55 @@ async def _ask_chat(self, call_id: str, tool_name: str, args_str: str) -> dict: "denying tool=%s immediately", tool_name, ) return {"permissionDecision": "deny"} - - logger.info( - "[hitl.chat] sending approval_request via WebSocket: " - "tool=%s call_id=%s", - tool_name, call_id, - ) - self._emit("approval_request", { - "call_id": call_id, - "tool": tool_name, - "arguments": args_str, - }) - logger.info("[hitl.chat] approval_request emitted, waiting for response...") - - loop = asyncio.get_running_loop() - future: asyncio.Future[bool] = loop.create_future() - self._pending[call_id] = future - - try: - approved = await asyncio.wait_for(future, timeout=_APPROVAL_TIMEOUT) - except asyncio.TimeoutError: - logger.warning("[hitl] approval timed out: call_id=%s tool=%s", call_id, tool_name) - approved = False - finally: - self._pending.pop(call_id, None) - - decision = "allow" if approved else "deny" - logger.info( - "[hitl.chat] decision: tool=%s call_id=%s approved=%s decision=%s", - tool_name, call_id, approved, decision, + return await ask_chat_approval( + emit=self._emit, + pending=self._pending, + call_id=call_id, + tool_name=tool_name, + args_str=args_str, ) - if self._emit: - self._emit("approval_resolved", { - "call_id": call_id, - "tool": tool_name, - "approved": approved, - }) - return {"permissionDecision": decision} async def _ask_bot_channel( self, call_id: str, tool_name: str, args_str: str, ) -> dict: assert self._bot_reply_fn is not None - truncated = args_str if len(args_str) <= 200 else args_str[:197] + "..." - confirmation_msg = ( - f"The agent wants to use the tool **{tool_name}**.\n\n" - f"Arguments: `{truncated}`\n\n" - f"Reply **y** to approve or anything else to deny." - ) - logger.info( - "[hitl] bot-channel approval request: tool=%s call_id=%s", - tool_name, call_id, - ) - try: - await self._bot_reply_fn(confirmation_msg) - except Exception: - logger.exception("[hitl] failed to send bot approval message: call_id=%s", call_id) - return {"permissionDecision": "deny"} - - loop = asyncio.get_running_loop() - future: asyncio.Future[bool] = loop.create_future() - self._pending[call_id] = future - - try: - approved = await asyncio.wait_for(future, timeout=_APPROVAL_TIMEOUT) - except asyncio.TimeoutError: - logger.warning("[hitl] bot approval timed out: call_id=%s tool=%s", call_id, tool_name) - approved = False - finally: - self._pending.pop(call_id, None) - - decision = "allow" if approved else "deny" - logger.info( - "[hitl] bot-channel decision: tool=%s call_id=%s decision=%s", - tool_name, call_id, decision, - ) - - outcome_msg = ( - f"Tool **{tool_name}** {'approved' if approved else 'denied'}." + return await ask_bot_approval( + bot_reply_fn=self._bot_reply_fn, + pending=self._pending, + call_id=call_id, + tool_name=tool_name, + args_str=args_str, ) - try: - await self._bot_reply_fn(outcome_msg) - except Exception: - logger.exception("[hitl] failed to send bot outcome message: call_id=%s", call_id) - - return {"permissionDecision": decision} async def _ask_phone(self, call_id: str, tool_name: str, args_str: str) -> dict: assert self._phone_verifier is not None - logger.info("[hitl] phone verification: tool=%s call_id=%s", tool_name, call_id) - - if self._emit: - self._emit("phone_verification_started", { - "call_id": call_id, - "tool": tool_name, - "arguments": args_str, - }) - - try: - approved = await self._phone_verifier.request_verification( - call_id=call_id, - tool_name=tool_name, - tool_args=args_str, - ) - except Exception: - logger.exception("[hitl] phone verification failed: call_id=%s", call_id) - approved = False - - decision = "allow" if approved else "deny" - logger.info("[hitl] phone decision: tool=%s call_id=%s decision=%s", tool_name, call_id, decision) - - if self._emit: - self._emit("phone_verification_complete", { - "call_id": call_id, - "tool": tool_name, - "approved": approved, - }) - - return {"permissionDecision": decision} + return await ask_phone_approval( + phone_verifier=self._phone_verifier, + emit=self._emit, + call_id=call_id, + tool_name=tool_name, + args_str=args_str, + ) async def _apply_aitl(self, call_id: str, tool_name: str, args_str: str) -> dict | None: assert self._aitl_reviewer is not None - if self._emit: - self._emit("aitl_review_started", { - "call_id": call_id, - "tool": tool_name, - }) - try: - approved, reason = await self._aitl_reviewer.review( - tool_name=tool_name, - arguments=args_str, - ) - except Exception: - logger.exception("[hitl] AITL review error: call_id=%s", call_id) - return None - - if self._emit: - self._emit("aitl_review_complete", { - "call_id": call_id, - "tool": tool_name, - "approved": approved, - "reason": reason, - }) - - decision = "allow" if approved else "deny" - logger.info( - "[hitl] AITL decision: tool=%s call_id=%s decision=%s reason=%s", - tool_name, call_id, decision, reason, + return await apply_aitl_review( + aitl_reviewer=self._aitl_reviewer, + emit=self._emit, + call_id=call_id, + tool_name=tool_name, + args_str=args_str, ) - return {"permissionDecision": decision} async def _apply_filter(self, call_id: str, tool_name: str, args_str: str) -> dict | None: assert self._prompt_shield is not None - import time as _time - - t0 = _time.monotonic() - try: - result = await run_sync(self._prompt_shield.check, args_str) - except Exception: - elapsed_ms = (_time.monotonic() - t0) * 1000 - logger.exception("[hitl] Prompt Shield error: call_id=%s", call_id) - self._last_shield_result = { - "result": "error", - "detail": "Shield check raised an exception", - "elapsed_ms": round(elapsed_ms, 1), - } - if self._tool_activity: - self._tool_activity.update_shield_result( - call_id=call_id, shield_result="error", - shield_detail="Shield check raised an exception", - shield_elapsed_ms=round(elapsed_ms, 1), - ) - return None - - elapsed_ms = (_time.monotonic() - t0) * 1000 - shield_status = "attack" if result.attack_detected else "clean" - self._last_shield_result = { - "result": shield_status, - "detail": result.detail, - "elapsed_ms": round(elapsed_ms, 1), - } - - if self._tool_activity: - self._tool_activity.update_shield_result( - call_id=call_id, - shield_result=shield_status, - shield_detail=result.detail, - shield_elapsed_ms=round(elapsed_ms, 1), - ) - - if result.attack_detected: - logger.info( - "[hitl] Prompt Shield denied: tool=%s call_id=%s detail=%s elapsed=%.0fms", - tool_name, call_id, result.detail, elapsed_ms, - ) - if self._emit: - self._emit("tool_denied", { - "call_id": call_id, - "tool": tool_name, - "reason": "Blocked by content filter", - "shield_detail": result.detail, - }) - return {"permissionDecision": "deny"} - - logger.info( - "[hitl] Prompt Shield passed: tool=%s call_id=%s elapsed=%.0fms", - tool_name, call_id, elapsed_ms, + decision, shield_info = await apply_filter_check( + prompt_shield=self._prompt_shield, + tool_activity=self._tool_activity, + emit=self._emit, + call_id=call_id, + tool_name=tool_name, + args_str=args_str, ) - return None + self._last_shield_result = shield_info + return decision diff --git a/app/runtime/agent/hitl_channels.py b/app/runtime/agent/hitl_channels.py new file mode 100644 index 0000000..f5bc063 --- /dev/null +++ b/app/runtime/agent/hitl_channels.py @@ -0,0 +1,275 @@ +"""Approval-channel implementations for the HITL interceptor.""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, Any + +from ..util.async_helpers import run_sync + +if TYPE_CHECKING: + from ..services.security.prompt_shield import PromptShieldService + from ..state.tool_activity_store import ToolActivityStore + from .aitl import AitlReviewer + from .phone_verify import PhoneVerifier + +logger = logging.getLogger(__name__) + +_APPROVAL_TIMEOUT = 300.0 + + +async def ask_chat_approval( + *, + emit: Callable[[str, dict[str, Any]], None], + pending: dict[str, asyncio.Future[bool]], + call_id: str, + tool_name: str, + args_str: str, + timeout: float = _APPROVAL_TIMEOUT, +) -> dict[str, str]: + """Request approval via the WebSocket chat channel.""" + logger.info( + "[hitl.chat] sending approval_request via WebSocket: " + "tool=%s call_id=%s", + tool_name, call_id, + ) + emit("approval_request", { + "call_id": call_id, + "tool": tool_name, + "arguments": args_str, + }) + logger.info("[hitl.chat] approval_request emitted, waiting for response...") + + loop = asyncio.get_running_loop() + future: asyncio.Future[bool] = loop.create_future() + pending[call_id] = future + + try: + approved = await asyncio.wait_for(future, timeout=timeout) + except asyncio.TimeoutError: + logger.warning("[hitl] approval timed out: call_id=%s tool=%s", call_id, tool_name) + approved = False + finally: + pending.pop(call_id, None) + + decision = "allow" if approved else "deny" + logger.info( + "[hitl.chat] decision: tool=%s call_id=%s approved=%s decision=%s", + tool_name, call_id, approved, decision, + ) + emit("approval_resolved", { + "call_id": call_id, + "tool": tool_name, + "approved": approved, + }) + return {"permissionDecision": decision} + + +async def ask_bot_approval( + *, + bot_reply_fn: Callable[[str], Awaitable[None]], + pending: dict[str, asyncio.Future[bool]], + call_id: str, + tool_name: str, + args_str: str, + timeout: float = _APPROVAL_TIMEOUT, +) -> dict[str, str]: + """Request approval via a messaging-bot reply channel.""" + truncated = args_str if len(args_str) <= 200 else args_str[:197] + "..." + confirmation_msg = ( + f"The agent wants to use the tool **{tool_name}**.\n\n" + f"Arguments: `{truncated}`\n\n" + f"Reply **y** to approve or anything else to deny." + ) + logger.info( + "[hitl] bot-channel approval request: tool=%s call_id=%s", + tool_name, call_id, + ) + try: + await bot_reply_fn(confirmation_msg) + except Exception: + logger.exception("[hitl] failed to send bot approval message: call_id=%s", call_id) + return {"permissionDecision": "deny"} + + loop = asyncio.get_running_loop() + future: asyncio.Future[bool] = loop.create_future() + pending[call_id] = future + + try: + approved = await asyncio.wait_for(future, timeout=timeout) + except asyncio.TimeoutError: + logger.warning("[hitl] bot approval timed out: call_id=%s tool=%s", call_id, tool_name) + approved = False + finally: + pending.pop(call_id, None) + + decision = "allow" if approved else "deny" + logger.info( + "[hitl] bot-channel decision: tool=%s call_id=%s decision=%s", + tool_name, call_id, decision, + ) + + outcome_msg = ( + f"Tool **{tool_name}** {'approved' if approved else 'denied'}." + ) + try: + await bot_reply_fn(outcome_msg) + except Exception: + logger.exception("[hitl] failed to send bot outcome message: call_id=%s", call_id) + + return {"permissionDecision": decision} + + +async def ask_phone_approval( + *, + phone_verifier: PhoneVerifier, + emit: Callable[[str, dict[str, Any]], None] | None, + call_id: str, + tool_name: str, + args_str: str, +) -> dict[str, str]: + """Request approval via phone verification.""" + logger.info("[hitl] phone verification: tool=%s call_id=%s", tool_name, call_id) + + if emit: + emit("phone_verification_started", { + "call_id": call_id, + "tool": tool_name, + "arguments": args_str, + }) + + try: + approved = await phone_verifier.request_verification( + call_id=call_id, + tool_name=tool_name, + tool_args=args_str, + ) + except Exception: + logger.exception("[hitl] phone verification failed: call_id=%s", call_id) + approved = False + + decision = "allow" if approved else "deny" + logger.info("[hitl] phone decision: tool=%s call_id=%s decision=%s", tool_name, call_id, decision) + + if emit: + emit("phone_verification_complete", { + "call_id": call_id, + "tool": tool_name, + "approved": approved, + }) + + return {"permissionDecision": decision} + + +async def apply_aitl_review( + *, + aitl_reviewer: AitlReviewer, + emit: Callable[[str, dict[str, Any]], None] | None, + call_id: str, + tool_name: str, + args_str: str, +) -> dict[str, str] | None: + """Run an AI-in-the-loop review. Returns decision or ``None`` on error.""" + if emit: + emit("aitl_review_started", { + "call_id": call_id, + "tool": tool_name, + }) + try: + approved, reason = await aitl_reviewer.review( + tool_name=tool_name, + arguments=args_str, + ) + except Exception: + logger.exception("[hitl] AITL review error: call_id=%s", call_id) + return None + + if emit: + emit("aitl_review_complete", { + "call_id": call_id, + "tool": tool_name, + "approved": approved, + "reason": reason, + }) + + decision = "allow" if approved else "deny" + logger.info( + "[hitl] AITL decision: tool=%s call_id=%s decision=%s reason=%s", + tool_name, call_id, decision, reason, + ) + return {"permissionDecision": decision} + + +async def apply_filter_check( + *, + prompt_shield: PromptShieldService, + tool_activity: ToolActivityStore | None, + emit: Callable[[str, dict[str, Any]], None] | None, + call_id: str, + tool_name: str, + args_str: str, +) -> tuple[dict[str, str] | None, dict[str, Any]]: + """Run a Prompt Shield content-safety check. + + Returns ``(decision | None, shield_result_info)``. When ``decision`` + is ``None`` the content passed the filter and the caller should + continue with the next step. + """ + import time as _time + + t0 = _time.monotonic() + try: + result = await run_sync(prompt_shield.check, args_str) + except Exception: + elapsed_ms = (_time.monotonic() - t0) * 1000 + logger.exception("[hitl] Prompt Shield error: call_id=%s", call_id) + shield_info: dict[str, Any] = { + "result": "error", + "detail": "Shield check raised an exception", + "elapsed_ms": round(elapsed_ms, 1), + } + if tool_activity: + tool_activity.update_shield_result( + call_id=call_id, shield_result="error", + shield_detail="Shield check raised an exception", + shield_elapsed_ms=round(elapsed_ms, 1), + ) + return None, shield_info + + elapsed_ms = (_time.monotonic() - t0) * 1000 + shield_status = "attack" if result.attack_detected else "clean" + shield_info = { + "result": shield_status, + "detail": result.detail, + "elapsed_ms": round(elapsed_ms, 1), + } + + if tool_activity: + tool_activity.update_shield_result( + call_id=call_id, + shield_result=shield_status, + shield_detail=result.detail, + shield_elapsed_ms=round(elapsed_ms, 1), + ) + + if result.attack_detected: + logger.info( + "[hitl] Prompt Shield denied: tool=%s call_id=%s detail=%s elapsed=%.0fms", + tool_name, call_id, result.detail, elapsed_ms, + ) + if emit: + emit("tool_denied", { + "call_id": call_id, + "tool": tool_name, + "reason": "Blocked by content filter", + "shield_detail": result.detail, + }) + return {"permissionDecision": "deny"}, shield_info + + logger.info( + "[hitl] Prompt Shield passed: tool=%s call_id=%s elapsed=%.0fms", + tool_name, call_id, elapsed_ms, + ) + return None, shield_info diff --git a/app/runtime/agent/prompt.py b/app/runtime/agent/prompt.py index 9b2c146..6a1c2d4 100644 --- a/app/runtime/agent/prompt.py +++ b/app/runtime/agent/prompt.py @@ -6,11 +6,11 @@ from ..config.settings import cfg -_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" +TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" def _load_template(name: str) -> str: - return (_TEMPLATES_DIR / name).read_text() + return (TEMPLATES_DIR / name).read_text() def load_soul() -> str: diff --git a/app/runtime/agent/tools.py b/app/runtime/agent/tools.py deleted file mode 100644 index dd724f8..0000000 --- a/app/runtime/agent/tools.py +++ /dev/null @@ -1,202 +0,0 @@ -"""Custom tools exposed to the Copilot agent.""" - -from __future__ import annotations - -import json -import logging -import threading -import urllib.error -import urllib.request - -from copilot import define_tool -from pydantic import BaseModel, Field - -from ..config.settings import cfg -from ..messaging.cards import CARD_TOOLS - -logger = logging.getLogger(__name__) - - -class ScheduleTaskParams(BaseModel): - description: str = Field(description="Human-readable description of the task") - prompt: str = Field(description="The prompt to send to the agent when this task fires") - cron: str | None = Field( - default=None, - description=( - "Cron expression for recurring tasks (minute hour day month weekday). " - "Minimum interval is every 1 hour. " - "Example: '0 9 * * *' for every day at 09:00 UTC." - ), - ) - run_at: str | None = Field( - default=None, - description="ISO datetime for one-shot tasks (e.g. '2026-02-07T14:00:00')", - ) - - -class CancelTaskParams(BaseModel): - task_id: str = Field(description="ID of the scheduled task to cancel") - - -class MakeCallParams(BaseModel): - prompt: str | None = Field( - default=None, - description="Optional custom prompt / instructions for the voice AI agent.", - ) - opening_message: str | None = Field( - default=None, - description="Optional opening message the AI should speak when the call connects.", - ) - - -class SearchMemoriesParams(BaseModel): - query: str = Field( - description="Natural language search query to find relevant memories.", - ) - top: int = Field(default=5, description="Maximum number of results to return (1-10).") - - -@define_tool( - description=( - "Schedule a future task. Provide either a cron expression for recurring " - "tasks (minimum every 1 hour) or a run_at datetime for one-shot tasks." - ) -) -def schedule_task(params: ScheduleTaskParams) -> dict: - from ..scheduler import get_scheduler - - scheduler = get_scheduler() - logger.info( - "[schedule_task] called: desc=%r, cron=%r, run_at=%r, prompt=%r", - params.description, params.cron, params.run_at, params.prompt[:80] if params.prompt else None, - ) - try: - task = scheduler.add( - description=params.description, - prompt=params.prompt, - cron=params.cron, - run_at=params.run_at, - ) - logger.info( - "[schedule_task] created task id=%s, run_at=%s, cron=%s, notify_cb=%s", - task.id, task.run_at, task.cron, - "SET" if scheduler._notify else "NOT SET", - ) - return {"id": task.id, "description": task.description, "status": "scheduled"} - except ValueError as exc: - logger.warning("[schedule_task] rejected: %s", exc) - return {"error": str(exc)} - - -@define_tool(description="Cancel a scheduled task by ID.") -def cancel_task(params: CancelTaskParams) -> str: - from ..scheduler import get_scheduler - - scheduler = get_scheduler() - return f"Task {params.task_id} cancelled." if scheduler.remove(params.task_id) else f"Task {params.task_id} not found." - - -@define_tool(description="List all scheduled tasks with their ID, description, schedule, and status.") -def list_scheduled_tasks() -> list[dict]: - from ..scheduler import get_scheduler - - return [ - { - "id": t.id, - "description": t.description, - "cron": t.cron, - "run_at": t.run_at, - "enabled": t.enabled, - "last_run": t.last_run, - } - for t in get_scheduler().list_tasks() - ] - - -@define_tool( - description=( - "Initiate an outbound voice call to the user. ALWAYS call this tool " - "when the user asks to be called -- the target phone number is managed " - "internally and you do not need to ask the user for it." - ) -) -def make_voice_call(params: MakeCallParams) -> dict: - target = cfg.voice_target_number - if not target: - return { - "status": "error", - "message": ( - "No target phone number configured yet. " - "Ask the user to run: /phone (e.g. /phone +14155551234)" - ), - } - url = f"http://127.0.0.1:{cfg.admin_port}/api/voice/call" - body: dict[str, str] = {"number": target} - if params.prompt: - body["prompt"] = params.prompt - if params.opening_message: - body["opening_message"] = params.opening_message - payload = json.dumps(body).encode("utf-8") - headers: dict[str, str] = {"Content-Type": "application/json"} - if cfg.admin_secret: - headers["Authorization"] = f"Bearer {cfg.admin_secret}" - req = urllib.request.Request(url, data=payload, headers=headers, method="POST") - - def _fire() -> None: - try: - with urllib.request.urlopen(req, timeout=30) as resp: - logger.info("Voice call API responded: %s", resp.read().decode()[:200]) - except Exception as exc: - logger.error("Voice call API request failed: %s", exc) - - threading.Thread(target=_fire, daemon=True).start() - return {"status": "ok", "message": "Call triggered"} - - -@define_tool( - description=( - "Search through indexed memories using Azure AI Search with vector " - "embeddings. Only works when Foundry IQ is enabled." - ) -) -def search_memories_tool(params: SearchMemoriesParams) -> dict: - from ..services.foundry_iq import search_memories - from ..state.foundry_iq_config import get_foundry_iq_config - - config = get_foundry_iq_config() - if not config.enabled or not config.is_configured: - return {"status": "skipped", "message": "Foundry IQ is not enabled."} - - try: - top = min(max(params.top, 1), 10) - data = search_memories(params.query, top, config) - if data.get("status") == "ok" and data.get("results"): - formatted = [ - { - "title": r.get("title", ""), - "content": r.get("content", ""), - "source_type": r.get("source_type", ""), - "date": r.get("date", ""), - } - for r in data["results"] - ] - return {"status": "ok", "results": formatted, "count": len(formatted)} - return {"status": "ok", "results": [], "count": 0, "message": "No matching memories found."} - except Exception as exc: - return {"status": "error", "message": f"Memory search failed: {exc}"} - - -ALL_TOOLS = [schedule_task, cancel_task, list_scheduled_tasks, make_voice_call] + CARD_TOOLS - - -def get_all_tools() -> list: - from ..state.foundry_iq_config import get_foundry_iq_config - - tools = list(ALL_TOOLS) - try: - fiq = get_foundry_iq_config() - if fiq.enabled and fiq.is_configured: - tools.append(search_memories_tool) - except Exception: - pass - return tools diff --git a/app/runtime/agent/tools/__init__.py b/app/runtime/agent/tools/__init__.py new file mode 100644 index 0000000..cfe850f --- /dev/null +++ b/app/runtime/agent/tools/__init__.py @@ -0,0 +1,42 @@ +"""Custom tools exposed to the Copilot agent.""" + +from .cards import CARD_TOOLS +from .memory import SearchMemoriesParams, search_memories_tool +from .scheduler import ( + CancelTaskParams, + ScheduleTaskParams, + cancel_task, + list_scheduled_tasks, + schedule_task, +) +from .voice import MakeCallParams, make_voice_call + +ALL_TOOLS = [schedule_task, cancel_task, list_scheduled_tasks, make_voice_call] + CARD_TOOLS + + +def get_all_tools() -> list: + from ...state.foundry_iq_config import get_foundry_iq_config + + tools = list(ALL_TOOLS) + try: + fiq = get_foundry_iq_config() + if fiq.enabled and fiq.is_configured: + tools.append(search_memories_tool) + except Exception: + pass + return tools + + +__all__ = [ + "ALL_TOOLS", + "CancelTaskParams", + "MakeCallParams", + "ScheduleTaskParams", + "SearchMemoriesParams", + "cancel_task", + "get_all_tools", + "list_scheduled_tasks", + "make_voice_call", + "schedule_task", + "search_memories_tool", +] diff --git a/app/runtime/agent/tools/cards.py b/app/runtime/agent/tools/cards.py new file mode 100644 index 0000000..bfbba5f --- /dev/null +++ b/app/runtime/agent/tools/cards.py @@ -0,0 +1,114 @@ +"""Card tool definitions for the Copilot agent. + +Wraps the card queue and attachment builders from the messaging layer +into ``@define_tool`` functions that the LLM can invoke. +""" + +from __future__ import annotations + +import json + +from copilot import define_tool +from pydantic import BaseModel, Field + +from ...messaging.cards import ( + _adaptive_card_attachment, + _default_queue, + _hero_card_attachment, + _thumbnail_card_attachment, +) + + +# -- parameter models ------------------------------------------------------ + + +class AdaptiveCardParams(BaseModel): + card_json: str = Field(description="The Adaptive Card payload as a JSON string.") + fallback_text: str = Field(default="", description="Plain-text fallback for unsupported clients.") + + +class HeroCardParams(BaseModel): + title: str = Field(default="", description="Card title") + subtitle: str = Field(default="", description="Card subtitle") + text: str = Field(default="", description="Card body text") + image_url: str | None = Field(default=None, description="URL of the card image") + buttons: str = Field(default="[]", description="JSON array of button objects.") + + +class ThumbnailCardParams(BaseModel): + title: str = Field(default="", description="Card title") + subtitle: str = Field(default="", description="Card subtitle") + text: str = Field(default="", description="Card body text") + image_url: str | None = Field(default=None, description="URL of the thumbnail image") + buttons: str = Field(default="[]", description="JSON array of button objects.") + + +class CardCarouselParams(BaseModel): + cards_json: str = Field(description="JSON array of card objects.") + + +# -- tool definitions ------------------------------------------------------ + + +@define_tool(description="Send an Adaptive Card to the user with rich layout support.") +def send_adaptive_card(params: AdaptiveCardParams) -> dict: + try: + card_data = json.loads(params.card_json) + except json.JSONDecodeError as exc: + return {"error": f"Invalid JSON: {exc}"} + if not isinstance(card_data, dict): + return {"error": "card_json must be a JSON object."} + _default_queue.enqueue(_adaptive_card_attachment(card_data)) + return {"status": "queued", "fallback_text": params.fallback_text, "elements": len(card_data.get("body", []))} + + +@define_tool(description="Send a Hero Card with large image, title, and action buttons.") +def send_hero_card(params: HeroCardParams) -> dict: + try: + buttons = json.loads(params.buttons) if params.buttons else [] + except json.JSONDecodeError: + buttons = [] + _default_queue.enqueue(_hero_card_attachment(title=params.title, subtitle=params.subtitle, text=params.text, image_url=params.image_url, buttons=buttons)) + return {"status": "queued", "title": params.title} + + +@define_tool(description="Send a Thumbnail Card with smaller image and compact layout.") +def send_thumbnail_card(params: ThumbnailCardParams) -> dict: + try: + buttons = json.loads(params.buttons) if params.buttons else [] + except json.JSONDecodeError: + buttons = [] + _default_queue.enqueue(_thumbnail_card_attachment(title=params.title, subtitle=params.subtitle, text=params.text, image_url=params.image_url, buttons=buttons)) + return {"status": "queued", "title": params.title} + + +@define_tool(description="Send multiple cards as a horizontal carousel.") +def send_card_carousel(params: CardCarouselParams) -> dict: + try: + cards = json.loads(params.cards_json) + except json.JSONDecodeError as exc: + return {"error": f"Invalid JSON: {exc}"} + if not isinstance(cards, list): + return {"error": "cards_json must be a JSON array."} + + count = 0 + for card in cards: + card_type = card.pop("type", "hero") + if card_type == "adaptive": + _default_queue.enqueue(_adaptive_card_attachment(card)) + elif card_type == "thumbnail": + buttons = card.get("buttons", []) + if isinstance(buttons, str): + buttons = json.loads(buttons) + _default_queue.enqueue(_thumbnail_card_attachment(title=card.get("title", ""), subtitle=card.get("subtitle", ""), text=card.get("text", ""), image_url=card.get("image_url"), buttons=buttons)) + else: + buttons = card.get("buttons", []) + if isinstance(buttons, str): + buttons = json.loads(buttons) + _default_queue.enqueue(_hero_card_attachment(title=card.get("title", ""), subtitle=card.get("subtitle", ""), text=card.get("text", ""), image_url=card.get("image_url"), buttons=buttons)) + count += 1 + + return {"status": "queued", "card_count": count} + + +CARD_TOOLS = [send_adaptive_card, send_hero_card, send_thumbnail_card, send_card_carousel] diff --git a/app/runtime/agent/tools/memory.py b/app/runtime/agent/tools/memory.py new file mode 100644 index 0000000..304fe8a --- /dev/null +++ b/app/runtime/agent/tools/memory.py @@ -0,0 +1,51 @@ +"""Memory search tool -- Foundry IQ vector search over indexed memories.""" + +from __future__ import annotations + +from copilot import define_tool +from pydantic import BaseModel, Field + + +class SearchMemoriesParams(BaseModel): + query: str = Field( + description="Natural language search query to find relevant memories.", + ) + top: int = Field(default=5, description="Maximum number of results to return (1-10).") + + +@define_tool( + description=( + "Search through indexed memories using Azure AI Search with vector " + "embeddings. Only works when Foundry IQ is enabled." + ) +) +def search_memories_tool(params: SearchMemoriesParams) -> dict: + from ...services.foundry_iq import search_memories + from ...state.foundry_iq_config import get_foundry_iq_config + + config = get_foundry_iq_config() + if not config.enabled or not config.is_configured: + return {"status": "skipped", "message": "Foundry IQ is not enabled."} + + try: + top = min(max(params.top, 1), 10) + data = search_memories(params.query, top, config) + if data.get("status") == "ok" and data.get("results"): + formatted = [ + { + "title": r.get("title", ""), + "content": r.get("content", ""), + "source_type": r.get("source_type", ""), + "date": r.get("date", ""), + } + for r in data["results"] + ] + return {"status": "ok", "results": formatted, "count": len(formatted)} + return { + "status": "ok", + "results": [], + "count": 0, + "message": "No matching memories found.", + } + except Exception as exc: + return {"status": "error", "message": f"Memory search failed: {exc}"} diff --git a/app/runtime/agent/tools/scheduler.py b/app/runtime/agent/tools/scheduler.py new file mode 100644 index 0000000..344a226 --- /dev/null +++ b/app/runtime/agent/tools/scheduler.py @@ -0,0 +1,94 @@ +"""Scheduler tools -- create, cancel, and list scheduled tasks.""" + +from __future__ import annotations + +import logging + +from copilot import define_tool +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + + +class ScheduleTaskParams(BaseModel): + description: str = Field(description="Human-readable description of the task") + prompt: str = Field(description="The prompt to send to the agent when this task fires") + cron: str | None = Field( + default=None, + description=( + "Cron expression for recurring tasks (minute hour day month weekday). " + "Minimum interval is every 1 hour. " + "Example: '0 9 * * *' for every day at 09:00 UTC." + ), + ) + run_at: str | None = Field( + default=None, + description="ISO datetime for one-shot tasks (e.g. '2026-02-07T14:00:00')", + ) + + +class CancelTaskParams(BaseModel): + task_id: str = Field(description="ID of the scheduled task to cancel") + + +@define_tool( + description=( + "Schedule a future task. Provide either a cron expression for recurring " + "tasks (minimum every 1 hour) or a run_at datetime for one-shot tasks." + ) +) +def schedule_task(params: ScheduleTaskParams) -> dict: + from ...scheduler import get_scheduler + + scheduler = get_scheduler() + logger.info( + "[schedule_task] called: desc=%r, cron=%r, run_at=%r, prompt=%r", + params.description, params.cron, params.run_at, params.prompt[:80] if params.prompt else None, + ) + try: + task = scheduler.add( + description=params.description, + prompt=params.prompt, + cron=params.cron, + run_at=params.run_at, + ) + logger.info( + "[schedule_task] created task id=%s, run_at=%s, cron=%s, notify_cb=%s", + task.id, task.run_at, task.cron, + "SET" if scheduler._notify else "NOT SET", + ) + return {"id": task.id, "description": task.description, "status": "scheduled"} + except ValueError as exc: + logger.warning("[schedule_task] rejected: %s", exc) + return {"error": str(exc)} + + +@define_tool(description="Cancel a scheduled task by ID.") +def cancel_task(params: CancelTaskParams) -> str: + from ...scheduler import get_scheduler + + scheduler = get_scheduler() + return ( + f"Task {params.task_id} cancelled." + if scheduler.remove(params.task_id) + else f"Task {params.task_id} not found." + ) + + +@define_tool( + description="List all scheduled tasks with their ID, description, schedule, and status.", +) +def list_scheduled_tasks() -> list[dict]: + from ...scheduler import get_scheduler + + return [ + { + "id": t.id, + "description": t.description, + "cron": t.cron, + "run_at": t.run_at, + "enabled": t.enabled, + "last_run": t.last_run, + } + for t in get_scheduler().list_tasks() + ] diff --git a/app/runtime/agent/tools/voice.py b/app/runtime/agent/tools/voice.py new file mode 100644 index 0000000..db88143 --- /dev/null +++ b/app/runtime/agent/tools/voice.py @@ -0,0 +1,67 @@ +"""Voice call tool -- initiate outbound calls to the user.""" + +from __future__ import annotations + +import json +import logging +import threading +import urllib.error +import urllib.request + +from copilot import define_tool +from pydantic import BaseModel, Field + +from ...config.settings import cfg + +logger = logging.getLogger(__name__) + + +class MakeCallParams(BaseModel): + prompt: str | None = Field( + default=None, + description="Optional custom prompt / instructions for the voice AI agent.", + ) + opening_message: str | None = Field( + default=None, + description="Optional opening message the AI should speak when the call connects.", + ) + + +@define_tool( + description=( + "Initiate an outbound voice call to the user. ALWAYS call this tool " + "when the user asks to be called -- the target phone number is managed " + "internally and you do not need to ask the user for it." + ) +) +def make_voice_call(params: MakeCallParams) -> dict: + target = cfg.voice_target_number + if not target: + return { + "status": "error", + "message": ( + "No target phone number configured yet. " + "Ask the user to run: /phone (e.g. /phone +14155551234)" + ), + } + url = f"http://127.0.0.1:{cfg.admin_port}/api/voice/call" + body: dict[str, str] = {"number": target} + if params.prompt: + body["prompt"] = params.prompt + if params.opening_message: + body["opening_message"] = params.opening_message + payload = json.dumps(body).encode("utf-8") + headers: dict[str, str] = {"Content-Type": "application/json"} + if cfg.admin_secret: + headers["Authorization"] = f"Bearer {cfg.admin_secret}" + req = urllib.request.Request(url, data=payload, headers=headers, method="POST") + + def _fire() -> None: + try: + with urllib.request.urlopen(req, timeout=30) as resp: + logger.info("Voice call API responded: %s", resp.read().decode()[:200]) + except Exception as exc: + logger.error("Voice call API request failed: %s", exc) + + threading.Thread(target=_fire, daemon=True).start() + return {"status": "ok", "message": "Call triggered"} diff --git a/app/runtime/env_cli.py b/app/runtime/env_cli.py index 239ffd6..75dbacb 100644 --- a/app/runtime/env_cli.py +++ b/app/runtime/env_cli.py @@ -7,8 +7,8 @@ import sys from dataclasses import asdict -from .services.azure import AzureCLI -from .services.misconfig_checker import MisconfigChecker +from .services.cloud.azure import AzureCLI +from .services.security.misconfig_checker import MisconfigChecker from .services.resource_tracker import ResourceTracker from .state.deploy_state import DeployStateStore diff --git a/app/runtime/media/__init__.py b/app/runtime/media/__init__.py index 75d37a6..51505bb 100644 --- a/app/runtime/media/__init__.py +++ b/app/runtime/media/__init__.py @@ -1,10 +1,11 @@ """Media handling -- type classification, download, and outgoing extraction.""" from .classify import EXTENSION_TO_MIME, classify -from .incoming import build_media_prompt, download_attachment, extract_outgoing_attachments +from .incoming import build_media_prompt, download_attachment from .outgoing import ( MAX_OUTGOING_FILE_BYTES, collect_pending_outgoing, + extract_outgoing_attachments, move_attachments_to_error, read_error_details, ) diff --git a/app/runtime/media/incoming.py b/app/runtime/media/incoming.py index 9cdf929..19721f1 100644 --- a/app/runtime/media/incoming.py +++ b/app/runtime/media/incoming.py @@ -2,11 +2,9 @@ from __future__ import annotations -import base64 import logging import mimetypes import os -import re import uuid from pathlib import Path @@ -14,7 +12,7 @@ from ..config.settings import cfg from ..util.async_helpers import run_sync -from .classify import EXTENSION_TO_MIME, classify +from .classify import classify logger = logging.getLogger(__name__) @@ -70,42 +68,3 @@ def build_media_prompt(user_text: str, saved_files: list[dict]) -> str: ] block = "\n".join(descriptions) return f"{block}\n\n{user_text}" if user_text else block - - -_FILE_PATH_RE = re.compile( - r"(?:^|\s)(/[\w./-]+\.(?:" + "|".join(ext.lstrip(".") for ext in EXTENSION_TO_MIME) + r"))\b", - re.IGNORECASE, -) - - -def extract_outgoing_attachments(response: str) -> list[Attachment]: - matches = _FILE_PATH_RE.findall(response) - attachments: list[Attachment] = [] - seen: set[str] = set() - - for file_path in matches: - if file_path in seen: - continue - seen.add(file_path) - - p = Path(file_path) - if not p.is_file(): - continue - - content_type = EXTENSION_TO_MIME.get(p.suffix.lower()) - if not content_type: - continue - - try: - data = base64.b64encode(p.read_bytes()).decode("ascii") - attachments.append( - Attachment( - name=p.name, - content_type=content_type, - content_url=f"data:{content_type};base64,{data}", - ) - ) - except Exception: - logger.exception("Failed to read media file %s", file_path) - - return attachments diff --git a/app/runtime/media/outgoing.py b/app/runtime/media/outgoing.py index 324924e..97fb736 100644 --- a/app/runtime/media/outgoing.py +++ b/app/runtime/media/outgoing.py @@ -5,6 +5,7 @@ import base64 import logging import mimetypes +import re import shutil import uuid from pathlib import Path @@ -200,3 +201,45 @@ def read_error_details() -> list[dict]: except OSError: continue return details + + +# -- inline response attachment extraction ---------------------------------- + +_FILE_PATH_RE = re.compile( + r"(?:^|\s)(/[\w./-]+\.(?:" + "|".join(ext.lstrip(".") for ext in EXTENSION_TO_MIME) + r"))\b", + re.IGNORECASE, +) + + +def extract_outgoing_attachments(response: str) -> list[Attachment]: + """Scan LLM response text for file paths and return base64-encoded attachments.""" + matches = _FILE_PATH_RE.findall(response) + attachments: list[Attachment] = [] + seen: set[str] = set() + + for file_path in matches: + if file_path in seen: + continue + seen.add(file_path) + + p = Path(file_path) + if not p.is_file(): + continue + + content_type = EXTENSION_TO_MIME.get(p.suffix.lower()) + if not content_type: + continue + + try: + data = base64.b64encode(p.read_bytes()).decode("ascii") + attachments.append( + Attachment( + name=p.name, + content_type=content_type, + content_url=f"data:{content_type};base64,{data}", + ) + ) + except Exception: + logger.exception("Failed to read media file %s", file_path) + + return attachments diff --git a/app/runtime/messaging/__init__.py b/app/runtime/messaging/__init__.py index 260f623..0564649 100644 --- a/app/runtime/messaging/__init__.py +++ b/app/runtime/messaging/__init__.py @@ -1,11 +1,12 @@ """Channel messaging pipeline -- bot handler, commands, cards, and formatting.""" +from .cards import CardQueue, drain_pending_cards +from .formatting import markdown_to_telegram, strip_markdown +from .proactive import ConversationReferenceStore, send_proactive_message + __all__ = [ "CardQueue", - "CommandDispatcher", "ConversationReferenceStore", - "MessageProcessor", - "Bot", "drain_pending_cards", "markdown_to_telegram", "send_proactive_message", diff --git a/app/runtime/messaging/cards.py b/app/runtime/messaging/cards.py index 703d015..7dc5a89 100644 --- a/app/runtime/messaging/cards.py +++ b/app/runtime/messaging/cards.py @@ -6,7 +6,6 @@ from __future__ import annotations -import json import logging import threading from typing import Any @@ -19,8 +18,8 @@ HeroCard, ThumbnailCard, ) -from copilot import define_tool -from pydantic import BaseModel, Field + +from ..util.singletons import register_singleton logger = logging.getLogger(__name__) @@ -52,6 +51,14 @@ def drain_pending_cards() -> list[Attachment]: return _default_queue.drain() +def _reset_default_queue() -> None: + """Drain the global card queue (for test isolation).""" + _default_queue.drain() + + +register_singleton(_reset_default_queue) + + # -- attachment builders --------------------------------------------------- @@ -148,98 +155,3 @@ def _serialize_model(obj: Any) -> Any: def _to_camel(snake: str) -> str: parts = snake.split("_") return parts[0] + "".join(p.capitalize() for p in parts[1:]) - - -# -- parameter models ------------------------------------------------------ - - -class AdaptiveCardParams(BaseModel): - card_json: str = Field(description="The Adaptive Card payload as a JSON string.") - fallback_text: str = Field(default="", description="Plain-text fallback for unsupported clients.") - - -class HeroCardParams(BaseModel): - title: str = Field(default="", description="Card title") - subtitle: str = Field(default="", description="Card subtitle") - text: str = Field(default="", description="Card body text") - image_url: str | None = Field(default=None, description="URL of the card image") - buttons: str = Field(default="[]", description="JSON array of button objects.") - - -class ThumbnailCardParams(BaseModel): - title: str = Field(default="", description="Card title") - subtitle: str = Field(default="", description="Card subtitle") - text: str = Field(default="", description="Card body text") - image_url: str | None = Field(default=None, description="URL of the thumbnail image") - buttons: str = Field(default="[]", description="JSON array of button objects.") - - -class CardCarouselParams(BaseModel): - cards_json: str = Field(description="JSON array of card objects.") - - -# -- tool definitions ------------------------------------------------------ - - -@define_tool(description="Send an Adaptive Card to the user with rich layout support.") -def send_adaptive_card(params: AdaptiveCardParams) -> dict: - try: - card_data = json.loads(params.card_json) - except json.JSONDecodeError as exc: - return {"error": f"Invalid JSON: {exc}"} - if not isinstance(card_data, dict): - return {"error": "card_json must be a JSON object."} - _default_queue.enqueue(_adaptive_card_attachment(card_data)) - return {"status": "queued", "fallback_text": params.fallback_text, "elements": len(card_data.get("body", []))} - - -@define_tool(description="Send a Hero Card with large image, title, and action buttons.") -def send_hero_card(params: HeroCardParams) -> dict: - try: - buttons = json.loads(params.buttons) if params.buttons else [] - except json.JSONDecodeError: - buttons = [] - _default_queue.enqueue(_hero_card_attachment(title=params.title, subtitle=params.subtitle, text=params.text, image_url=params.image_url, buttons=buttons)) - return {"status": "queued", "title": params.title} - - -@define_tool(description="Send a Thumbnail Card with smaller image and compact layout.") -def send_thumbnail_card(params: ThumbnailCardParams) -> dict: - try: - buttons = json.loads(params.buttons) if params.buttons else [] - except json.JSONDecodeError: - buttons = [] - _default_queue.enqueue(_thumbnail_card_attachment(title=params.title, subtitle=params.subtitle, text=params.text, image_url=params.image_url, buttons=buttons)) - return {"status": "queued", "title": params.title} - - -@define_tool(description="Send multiple cards as a horizontal carousel.") -def send_card_carousel(params: CardCarouselParams) -> dict: - try: - cards = json.loads(params.cards_json) - except json.JSONDecodeError as exc: - return {"error": f"Invalid JSON: {exc}"} - if not isinstance(cards, list): - return {"error": "cards_json must be a JSON array."} - - count = 0 - for card in cards: - card_type = card.pop("type", "hero") - if card_type == "adaptive": - _default_queue.enqueue(_adaptive_card_attachment(card)) - elif card_type == "thumbnail": - buttons = card.get("buttons", []) - if isinstance(buttons, str): - buttons = json.loads(buttons) - _default_queue.enqueue(_thumbnail_card_attachment(title=card.get("title", ""), subtitle=card.get("subtitle", ""), text=card.get("text", ""), image_url=card.get("image_url"), buttons=buttons)) - else: - buttons = card.get("buttons", []) - if isinstance(buttons, str): - buttons = json.loads(buttons) - _default_queue.enqueue(_hero_card_attachment(title=card.get("title", ""), subtitle=card.get("subtitle", ""), text=card.get("text", ""), image_url=card.get("image_url"), buttons=buttons)) - count += 1 - - return {"status": "queued", "card_count": count} - - -CARD_TOOLS = [send_adaptive_card, send_hero_card, send_thumbnail_card, send_card_carousel] diff --git a/app/runtime/messaging/commands.py b/app/runtime/messaging/commands.py deleted file mode 100644 index b0c4efe..0000000 --- a/app/runtime/messaging/commands.py +++ /dev/null @@ -1,582 +0,0 @@ -"""Shared slash-command dispatcher. - -Centralises all slash-command logic so both the Bot Framework handler -and the WebSocket chat handler share a single implementation. -""" - -from __future__ import annotations - -import time -import uuid -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from typing import Any, Protocol - -from ..agent.agent import Agent -from ..config.settings import cfg -from ..registries.plugins import get_plugin_registry -from ..registries.skills import get_registry as get_skill_registry -from ..scheduler import get_scheduler -from ..state.infra_config import InfraConfigStore -from ..state.mcp_config import McpConfigStore -from ..state.profile import load_profile -from ..state.session_store import SessionStore - -BOOT_TIME = time.monotonic() - -ReplyFn = Callable[[str], Awaitable[None]] - - -class ChannelContext(Protocol): - @property - def conversation_refs_count(self) -> int: ... - - @property - def connected_channels(self) -> set[str]: ... - - @property - def conversation_refs(self) -> list[Any]: ... - - -@dataclass -class CommandContext: - text: str - reply: ReplyFn - channel: str - channel_ctx: ChannelContext | None = None - - -class CommandDispatcher: - _EXACT_COMMANDS: dict[str, str] = { - "/new": "_cmd_new", - "/status": "_cmd_status", - "/skills": "_cmd_skills", - "/session": "_cmd_session", - "/channels": "_cmd_channels", - "/clear": "_cmd_clear", - "/help": "_cmd_help", - "/plugins": "_cmd_plugins", - "/mcp": "_cmd_mcp", - "/schedules": "_cmd_schedules", - "/sessions": "_cmd_sessions", - "/profile": "_cmd_profile", - "/config": "_cmd_config", - "/preflight": "_cmd_preflight", - "/call": "_cmd_call", - "/models": "_cmd_models", - "/change": "_cmd_change", - } - - _PREFIX_COMMANDS: tuple[tuple[str, str], ...] = ( - ("/removeskill", "_cmd_removeskill"), - ("/addskill", "_cmd_addskill"), - ("/model", "_cmd_model"), - ("/plugin", "_cmd_plugin"), - ("/mcp", "_cmd_mcp"), - ("/schedule", "_cmd_schedule"), - ("/sessions", "_cmd_sessions_sub"), - ("/session", "_cmd_session_sub"), - ("/config", "_cmd_config"), - ("/phone", "_cmd_phone"), - ("/lockdown", "_cmd_lockdown"), - ) - - def __init__( - self, - agent: Agent, - session_store: SessionStore | None = None, - infra: InfraConfigStore | None = None, - ) -> None: - self._agent = agent - self._session_store = session_store - self._infra = infra - - @property - def infra(self) -> InfraConfigStore: - if self._infra is None: - self._infra = InfraConfigStore() - return self._infra - - async def try_handle( - self, - text: str, - reply: ReplyFn, - channel: str = "web", - *, - channel_ctx: ChannelContext | None = None, - ) -> bool: - lower = text.lower() - ctx = CommandContext(text=text, reply=reply, channel=channel, channel_ctx=channel_ctx) - - handler_name = self._EXACT_COMMANDS.get(lower) - if handler_name: - await getattr(self, handler_name)(ctx) - return True - - for prefix, handler_name in self._PREFIX_COMMANDS: - if lower.startswith(prefix): - await getattr(self, handler_name)(ctx) - return True - - return False - - async def _cmd_new(self, ctx: CommandContext) -> None: - await self._agent.new_session() - if self._session_store: - self._session_store.start_session(uuid.uuid4().hex[:12], model=cfg.copilot_model) - await ctx.reply("New session started.") - - async def _cmd_model(self, ctx: CommandContext) -> None: - parts = ctx.text.split(maxsplit=1) - if len(parts) < 2: - await ctx.reply(f"Current model: {cfg.copilot_model}\n\nUsage: /model ") - return - new_model = parts[1].strip() - old_model = cfg.copilot_model - cfg.write_env(COPILOT_MODEL=new_model) - await self._agent.new_session() - if self._session_store: - self._session_store.start_session(uuid.uuid4().hex[:12], model=new_model) - await ctx.reply(f"Model switched: {old_model} -> {new_model}\nNew session started.") - - async def _cmd_models(self, ctx: CommandContext) -> None: - models = await self._agent.list_models() - if not models: - await ctx.reply("No models available.") - return - current = cfg.copilot_model - lines = ["Available Models", ""] - for m in models: - marker = " *" if m["id"] == current else "" - cost = f" ({m['billing_multiplier']}x)" if m.get("billing_multiplier", 1.0) != 1.0 else "" - reasoning = f" [reasoning: {', '.join(m['reasoning_efforts'])}]" if m.get("reasoning_efforts") else "" - policy = m.get("policy", "enabled") - if policy != "enabled": - lines.append(f" {m['id']}{marker}{cost} ({policy})") - else: - lines.append(f" {m['id']}{marker}{cost}{reasoning}") - lines.append(f"\nCurrent: {current}\nUse /model to switch.") - await ctx.reply("\n".join(lines)) - - async def _cmd_status(self, ctx: CommandContext) -> None: - uptime_seconds = int(time.monotonic() - BOOT_TIME) - hours, remainder = divmod(uptime_seconds, 3600) - minutes, seconds = divmod(remainder, 60) - - sched = get_scheduler() - tasks = sched.list_tasks() - active_tasks = [t for t in tasks if t.enabled] - total_reqs = sum(self._agent.request_counts.values()) - - lines = [ - "System Status", - f" Model: {cfg.copilot_model}", - f" Uptime: {hours}h {minutes}m {seconds}s", - f" Total requests: {total_reqs}", - ] - for model, count in sorted(self._agent.request_counts.items()): - lines.append(f" {model}: {count}") - if ctx.channel_ctx is not None: - channels = ctx.channel_ctx.connected_channels - lines.append(f" Connected channels: {', '.join(sorted(channels)) or 'none'}") - lines.append(f" Conversation refs: {ctx.channel_ctx.conversation_refs_count}") - lines.append(f" Scheduled tasks: {len(active_tasks)} active / {len(tasks)} total") - lines.append(f" Data dir: {cfg.data_dir}") - await ctx.reply("\n".join(lines)) - - async def _cmd_skills(self, ctx: CommandContext) -> None: - skills: list[str] = [] - if cfg.user_skills_dir.is_dir(): - for d in sorted(cfg.user_skills_dir.iterdir()): - if d.is_dir() and (d / "SKILL.md").exists(): - skills.append(d.name) - lines = [f"Skills ({len(skills)}):"] + [f" - {name}" for name in skills] - if not skills: - lines.append(" (none)") - await ctx.reply("\n".join(lines)) - - async def _cmd_session(self, ctx: CommandContext) -> None: - lines = [ - "Session Info", - f" Active: {'yes' if self._agent.has_session else 'no'}", - f" Model: {cfg.copilot_model}", - " Playwright MCP: enabled", - ] - await ctx.reply("\n".join(lines)) - - async def _cmd_channels(self, ctx: CommandContext) -> None: - lines = ["Channel Configuration\n"] - tg = self.infra.channels.telegram - if tg.token: - masked = tg.token[:8] + "..." + tg.token[-4:] if len(tg.token) > 12 else "***" - lines.append(f"Telegram:\n Token: {masked}\n Whitelist: {tg.whitelist or '(none)'}") - else: - lines.append("Telegram: not configured") - lines.append(f"\nBot Framework:\n App ID: {cfg.bot_app_id[:8] + '...' if cfg.bot_app_id else 'not set'}") - lines.append(f" Tenant: {cfg.bot_app_tenant_id[:8] + '...' if cfg.bot_app_tenant_id else 'not set'}") - lines.append(f" Admin secret: {'set' if cfg.admin_secret else 'not set'}") - if ctx.channel_ctx is not None: - refs = ctx.channel_ctx.conversation_refs - lines.append(f"\nActive Conversations ({len(refs)}):") - for r in refs: - user_name = r.user.name if r.user else "?" - lines.append(f" - {r.channel_id}: {user_name}") - await ctx.reply("\n".join(lines)) - - async def _cmd_clear(self, ctx: CommandContext) -> None: - cleared = 0 - if cfg.memory_dir.is_dir(): - for f in cfg.memory_dir.rglob("*"): - if f.is_file(): - f.unlink() - cleared += 1 - await ctx.reply(f"Memory cleared ({cleared} files removed).") - - async def _cmd_addskill(self, ctx: CommandContext) -> None: - parts = ctx.text.split(maxsplit=1) - if len(parts) < 2: - reg = get_skill_registry() - try: - catalog = await reg.fetch_catalog() - available = [s for s in catalog if not s.installed] - if available: - lines = [f"Available skills ({len(available)}):"] - for s in available: - desc = f" - {s.description}" if s.description else "" - lines.append(f" {s.name}{desc} [{s.source}]") - lines.append("\nUsage: /addskill ") - else: - lines = ["All catalog skills already installed.", "Usage: /addskill "] - except Exception as exc: - lines = [f"Failed to fetch catalog: {exc}", "Usage: /addskill "] - await ctx.reply("\n".join(lines)) - return - name = parts[1].strip() - reg = get_skill_registry() - await ctx.reply(f"Installing skill '{name}'...") - ok = await reg.install(name) - await ctx.reply(f"Skill '{name}' installed." if ok else f"Failed to install skill '{name}'.") - - async def _cmd_removeskill(self, ctx: CommandContext) -> None: - parts = ctx.text.split(maxsplit=1) - if len(parts) < 2: - reg = get_skill_registry() - installed = reg.list_installed() - if installed: - lines = [f"Installed skills ({len(installed)}):"] + [f" {s.name}" for s in installed] - lines.append("\nUsage: /removeskill ") - else: - lines = ["No skills installed.", "Usage: /removeskill "] - await ctx.reply("\n".join(lines)) - return - name = parts[1].strip() - reg = get_skill_registry() - removed = reg.remove(name) - await ctx.reply(f"Skill '{name}' removed." if removed else f"Skill '{name}' not found.") - - async def _cmd_plugins(self, ctx: CommandContext) -> None: - reg = get_plugin_registry() - plugins = reg.list_plugins() - if not plugins: - await ctx.reply("No plugins found.") - return - lines = [f"Plugins ({len(plugins)}):"] - for p in plugins: - icon = "+" if p.get("enabled") else "-" - desc = f" - {p['description']}" if p.get("description") else "" - lines.append(f" [{icon}] {p['id']}{desc} ({p.get('skill_count', 0)} skills)") - lines.append("\nUsage: /plugin enable , /plugin disable ") - await ctx.reply("\n".join(lines)) - - async def _cmd_plugin(self, ctx: CommandContext) -> None: - parts = ctx.text.split() - if len(parts) < 3: - await ctx.reply("Usage: /plugin enable or /plugin disable ") - return - action, plugin_id = parts[1].lower(), parts[2].strip() - reg = get_plugin_registry() - if action == "enable": - result = reg.enable_plugin(plugin_id) - await ctx.reply(f"Plugin '{plugin_id}' enabled." if result else f"Plugin '{plugin_id}' not found.") - elif action == "disable": - result = reg.disable_plugin(plugin_id) - await ctx.reply(f"Plugin '{plugin_id}' disabled." if result else f"Plugin '{plugin_id}' not found.") - else: - await ctx.reply(f"Unknown action '{action}'.") - - async def _cmd_mcp(self, ctx: CommandContext) -> None: - parts = ctx.text.split() - store = McpConfigStore() - if len(parts) == 1: - servers = store.list_servers() - if not servers: - await ctx.reply("No MCP servers configured.") - return - lines = [f"MCP Servers ({len(servers)}):"] - for s in servers: - icon = "+" if s.get("enabled") else "-" - builtin = " [builtin]" if s.get("builtin") else "" - lines.append(f" [{icon}] {s['name']} ({s.get('type', '?')}){builtin}") - if s.get("description"): - lines.append(f" {s['description']}") - await ctx.reply("\n".join(lines)) - return - - action = parts[1].lower() - if action == "add": - if len(parts) < 4: - await ctx.reply("Usage: /mcp add ") - return - try: - store.add_server(parts[2], "http", url=parts[3]) - await ctx.reply(f"MCP server '{parts[2]}' added. Start a /new session to activate.") - except ValueError as exc: - await ctx.reply(f"Error: {exc}") - elif action == "remove": - if len(parts) < 3: - await ctx.reply("Usage: /mcp remove ") - return - try: - ok = store.remove_server(parts[2]) - await ctx.reply(f"MCP server '{parts[2]}' removed." if ok else f"MCP server '{parts[2]}' not found.") - except ValueError as exc: - await ctx.reply(f"Error: {exc}") - elif action in ("enable", "disable"): - if len(parts) < 3: - await ctx.reply(f"Usage: /mcp {action} ") - return - ok = store.set_enabled(parts[2], action == "enable") - await ctx.reply(f"MCP server '{parts[2]}' {action}d." if ok else f"MCP server '{parts[2]}' not found.") - else: - await ctx.reply(f"Unknown MCP action '{action}'.") - - async def _cmd_schedules(self, ctx: CommandContext) -> None: - sched = get_scheduler() - tasks = sched.list_tasks() - if not tasks: - await ctx.reply("No scheduled tasks.\n\nUsage: /schedule add ") - return - lines = [f"Scheduled Tasks ({len(tasks)}):"] - for t in tasks: - icon = "+" if t.enabled else "-" - schedule = t.cron or (f"once at {t.run_at}" if t.run_at else "?") - lines.append(f" [{icon}] {t.id} - {t.description}") - lines.append(f" Schedule: {schedule} | Last run: {t.last_run[:16] if t.last_run else 'never'}") - await ctx.reply("\n".join(lines)) - - async def _cmd_schedule(self, ctx: CommandContext) -> None: - parts = ctx.text.split() - if len(parts) < 2: - await ctx.reply("Usage: /schedule add or /schedule remove ") - return - action = parts[1].lower() - sched = get_scheduler() - if action == "add": - if len(parts) < 8: - await ctx.reply("Usage: /schedule add ") - return - cron = " ".join(parts[2:7]) - prompt = " ".join(parts[7:]) - try: - task = sched.add(description=prompt[:60], prompt=prompt, cron=cron) - await ctx.reply(f"Scheduled task created:\n ID: {task.id}\n Cron: {cron}\n Prompt: {prompt}") - except ValueError as exc: - await ctx.reply(f"Error: {exc}") - elif action == "remove": - if len(parts) < 3: - await ctx.reply("Usage: /schedule remove ") - return - ok = sched.remove(parts[2]) - await ctx.reply(f"Task '{parts[2]}' removed." if ok else f"Task '{parts[2]}' not found.") - - async def _cmd_sessions(self, ctx: CommandContext) -> None: - if not self._session_store: - await ctx.reply("Session store not available.") - return - sessions = self._session_store.list_sessions() - if not sessions: - await ctx.reply("No recorded sessions.") - return - stats = self._session_store.get_session_stats() - lines = [f"Sessions ({stats['total_sessions']} total, {stats['total_messages']} messages)", ""] - for s in sessions[:10]: - started = s.get("started_at", "?")[:16] - preview = s.get("first_message", "")[:50] - lines.append(f" {s['id']} {started} {s.get('model', '?')} ({s.get('message_count', 0)} msgs)") - if len(sessions) > 10: - lines.append(f" ... and {len(sessions) - 10} more") - await ctx.reply("\n".join(lines)) - - async def _cmd_sessions_sub(self, ctx: CommandContext) -> None: - parts = ctx.text.split() - if len(parts) >= 2 and parts[1].lower() == "clear": - if not self._session_store: - await ctx.reply("Session store not available.") - return - count = self._session_store.clear_all() - await ctx.reply(f"All sessions cleared ({count} deleted).") - else: - await self._cmd_sessions(ctx) - - async def _cmd_session_sub(self, ctx: CommandContext) -> None: - parts = ctx.text.split() - if len(parts) >= 3 and parts[1].lower() == "delete": - if not self._session_store: - await ctx.reply("Session store not available.") - return - ok = self._session_store.delete_session(parts[2]) - await ctx.reply(f"Session '{parts[2]}' deleted." if ok else f"Session '{parts[2]}' not found.") - else: - await self._cmd_session(ctx) - - async def _cmd_profile(self, ctx: CommandContext) -> None: - profile = load_profile() - lines = [ - "Agent Profile", - f" Name: {profile.get('name') or '(not set)'}", - f" Location: {profile.get('location') or '(not set)'}", - f" Emotional state: {profile.get('emotional_state', 'neutral')}", - ] - prefs = profile.get("preferences", {}) - if prefs: - lines.append(" Preferences:") - for k, v in prefs.items(): - lines.append(f" {k}: {v}") - await ctx.reply("\n".join(lines)) - - async def _cmd_config(self, ctx: CommandContext) -> None: - parts = ctx.text.split(maxsplit=2) - if len(parts) == 1: - lines = [ - "Runtime Configuration", - f" Model: {cfg.copilot_model}", - f" Admin port: {cfg.admin_port}", - f" Bot port: {cfg.bot_port}", - f" Data dir: {cfg.data_dir}", - f" Admin secret: {'set' if cfg.admin_secret else 'not set'}", - "\nUsage: /config ", - ] - await ctx.reply("\n".join(lines)) - return - if len(parts) < 3: - await ctx.reply("Usage: /config ") - return - key = parts[1].upper() - allowed = {"COPILOT_MODEL", "ADMIN_PORT", "BOT_PORT", "VOICE_TARGET_NUMBER", "ACS_SOURCE_NUMBER"} - if key not in allowed: - await ctx.reply(f"Cannot set '{key}'. Allowed keys: {', '.join(sorted(allowed))}") - return - cfg.write_env(**{key: parts[2]}) - await ctx.reply(f"Config updated: {key} = {parts[2]}") - - async def _cmd_preflight(self, ctx: CommandContext) -> None: - import aiohttp as _aiohttp - - base = f"http://127.0.0.1:{cfg.admin_port}" - headers = {"Authorization": f"Bearer {cfg.admin_secret}"} if cfg.admin_secret else {} - try: - async with _aiohttp.ClientSession() as session: - async with session.get(f"{base}/api/setup/preflight", headers=headers, timeout=_aiohttp.ClientTimeout(total=30)) as resp: - if resp.status != 200: - await ctx.reply(f"Preflight check failed (HTTP {resp.status}).") - return - data = await resp.json() - except Exception as exc: - await ctx.reply(f"Cannot reach preflight endpoint: {exc}") - return - - checks = data.get("checks", []) - lines = [f"Preflight Checks ({data.get('status', '?').upper()})"] - for c in checks: - icon = "OK" if c.get("ok") else "!!" - lines.append(f" [{icon}] {c['check']}: {c.get('detail', '')}") - await ctx.reply("\n".join(lines)) - - async def _cmd_phone(self, ctx: CommandContext) -> None: - parts = ctx.text.split(maxsplit=1) - if len(parts) < 2: - await ctx.reply(f"Current target number: {cfg.voice_target_number or '(not set)'}\n\nUsage: /phone ") - return - number = parts[1].strip() - if not number.startswith("+"): - await ctx.reply("Phone number must start with + country code.") - return - cfg.write_env(VOICE_TARGET_NUMBER=number) - await ctx.reply(f"Voice target number set to {number}.") - - async def _cmd_call(self, ctx: CommandContext) -> None: - import aiohttp as _aiohttp - - target = cfg.voice_target_number - if not target: - await ctx.reply("No target number configured. Use /phone first.") - return - base = f"http://127.0.0.1:{cfg.admin_port}" - headers = {"Authorization": f"Bearer {cfg.admin_secret}"} if cfg.admin_secret else {} - try: - async with _aiohttp.ClientSession() as session: - async with session.post(f"{base}/api/voice/call", json={"target_number": target}, headers=headers, timeout=_aiohttp.ClientTimeout(total=30)) as resp: - data = await resp.json() - if resp.status == 200: - await ctx.reply(f"Calling {target}...") - else: - await ctx.reply(f"Call failed: {data.get('error', f'HTTP {resp.status}')}") - except Exception as exc: - await ctx.reply(f"Call failed: {exc}") - - async def _cmd_change(self, ctx: CommandContext) -> None: - if not self._session_store: - await ctx.reply("Session store not available.") - return - sessions = self._session_store.list_sessions() - if not sessions: - await ctx.reply("No sessions to switch to. Use /new to start one.") - return - lines = ["Recent Sessions:", ""] - for i, s in enumerate(sessions[:5], 1): - started = s.get("started_at", "?")[:16] - lines.append(f" {i}. {started} {s.get('model', '?')} ({s.get('message_count', 0)} msgs)") - lines.append(f" ID: {s['id']}") - await ctx.reply("\n".join(lines)) - - async def _cmd_lockdown(self, ctx: CommandContext) -> None: - parts = ctx.text.split() - if len(parts) < 2: - state = "ENABLED" if cfg.lockdown_mode else "disabled" - await ctx.reply(f"Lock Down Mode: {state}\n\nUsage: /lockdown on | /lockdown off") - return - action = parts[1].lower() - if action not in ("on", "off"): - await ctx.reply("Usage: /lockdown on | /lockdown off") - return - if action == "on": - if cfg.lockdown_mode: - await ctx.reply("Lock Down Mode is already enabled.") - return - cfg.write_env(LOCKDOWN_MODE="1", TUNNEL_RESTRICTED="1") - from ..services.azure import AzureCLI - az = AzureCLI() - az.ok("logout") - az.invalidate_cache("account", "show") - await ctx.reply("Lock Down Mode ENABLED\n\n - Azure CLI logged out\n - Admin panel disabled") - else: - if not cfg.lockdown_mode: - await ctx.reply("Lock Down Mode is already disabled.") - return - cfg.write_env(LOCKDOWN_MODE="", TUNNEL_RESTRICTED="") - await ctx.reply("Lock Down Mode DISABLED\n\n - Admin panel re-enabled") - - async def _cmd_help(self, ctx: CommandContext) -> None: - lines = [ - "Available Commands", - "", - " /new, /model , /models, /status, /session, /config", - " /skills, /addskill , /removeskill ", - " /plugins, /plugin enable|disable ", - " /mcp, /mcp add|remove|enable|disable ", - " /schedules, /schedule add|remove", - " /sessions, /session delete , /sessions clear", - " /change, /profile, /channels, /clear", - " /phone , /call, /preflight, /lockdown, /help", - ] - await ctx.reply("\n".join(lines)) diff --git a/app/runtime/messaging/commands/__init__.py b/app/runtime/messaging/commands/__init__.py new file mode 100644 index 0000000..209745d --- /dev/null +++ b/app/runtime/messaging/commands/__init__.py @@ -0,0 +1,24 @@ +"""Slash-command dispatcher and command implementations. + +Sub-modules group commands by domain: + +- ``agent`` -- skills, plugins, MCP, schedules +- ``session`` -- session lifecycle and model switching +- ``system`` -- status, infra, and connectivity commands +""" + +from ._dispatcher import ( + ChannelContext, + CommandContext, + CommandDispatcher, + ReplyFn, +) +from .system import BOOT_TIME + +__all__ = [ + "BOOT_TIME", + "ChannelContext", + "CommandContext", + "CommandDispatcher", + "ReplyFn", +] diff --git a/app/runtime/messaging/commands/_dispatcher.py b/app/runtime/messaging/commands/_dispatcher.py new file mode 100644 index 0000000..a8beee3 --- /dev/null +++ b/app/runtime/messaging/commands/_dispatcher.py @@ -0,0 +1,199 @@ +"""Shared slash-command dispatcher. + +Centralises all slash-command logic so both the Bot Framework handler +and the WebSocket chat handler share a single implementation. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any, Protocol + +from ...agent.agent import Agent +from ...state.infra_config import InfraConfigStore +from ...state.session_store import SessionStore + +from . import agent as _agent_cmds +from . import session as _session_cmds +from . import system as _system_cmds + +ReplyFn = Callable[[str], Awaitable[None]] + + +class ChannelContext(Protocol): + @property + def conversation_refs_count(self) -> int: ... + + @property + def connected_channels(self) -> set[str]: ... + + @property + def conversation_refs(self) -> list[Any]: ... + + +@dataclass +class CommandContext: + text: str + reply: ReplyFn + channel: str + channel_ctx: ChannelContext | None = None + + +class CommandDispatcher: + _EXACT_COMMANDS: dict[str, str] = { + "/new": "_cmd_new", + "/status": "_cmd_status", + "/skills": "_cmd_skills", + "/session": "_cmd_session", + "/channels": "_cmd_channels", + "/clear": "_cmd_clear", + "/help": "_cmd_help", + "/plugins": "_cmd_plugins", + "/mcp": "_cmd_mcp", + "/schedules": "_cmd_schedules", + "/sessions": "_cmd_sessions", + "/profile": "_cmd_profile", + "/config": "_cmd_config", + "/preflight": "_cmd_preflight", + "/call": "_cmd_call", + "/models": "_cmd_models", + "/change": "_cmd_change", + } + + _PREFIX_COMMANDS: tuple[tuple[str, str], ...] = ( + ("/removeskill", "_cmd_removeskill"), + ("/addskill", "_cmd_addskill"), + ("/model", "_cmd_model"), + ("/plugin", "_cmd_plugin"), + ("/mcp", "_cmd_mcp"), + ("/schedule", "_cmd_schedule"), + ("/sessions", "_cmd_sessions_sub"), + ("/session", "_cmd_session_sub"), + ("/config", "_cmd_config"), + ("/phone", "_cmd_phone"), + ("/lockdown", "_cmd_lockdown"), + ) + + def __init__( + self, + agent: Agent, + session_store: SessionStore | None = None, + infra: InfraConfigStore | None = None, + ) -> None: + self._agent = agent + self._session_store = session_store + self._infra = infra + + @property + def infra(self) -> InfraConfigStore: + if self._infra is None: + self._infra = InfraConfigStore() + return self._infra + + async def try_handle( + self, + text: str, + reply: ReplyFn, + channel: str = "web", + *, + channel_ctx: ChannelContext | None = None, + ) -> bool: + lower = text.lower() + ctx = CommandContext(text=text, reply=reply, channel=channel, channel_ctx=channel_ctx) + + handler_name = self._EXACT_COMMANDS.get(lower) + if handler_name: + await getattr(self, handler_name)(ctx) + return True + + for prefix, handler_name in self._PREFIX_COMMANDS: + if lower.startswith(prefix): + await getattr(self, handler_name)(ctx) + return True + + return False + + # -- Session & model commands (delegated to commands_session) ----------- + + async def _cmd_new(self, ctx: CommandContext) -> None: + await _session_cmds.cmd_new(self, ctx) + + async def _cmd_model(self, ctx: CommandContext) -> None: + await _session_cmds.cmd_model(self, ctx) + + async def _cmd_models(self, ctx: CommandContext) -> None: + await _session_cmds.cmd_models(self, ctx) + + async def _cmd_session(self, ctx: CommandContext) -> None: + await _session_cmds.cmd_session(self, ctx) + + async def _cmd_sessions(self, ctx: CommandContext) -> None: + await _session_cmds.cmd_sessions(self, ctx) + + async def _cmd_sessions_sub(self, ctx: CommandContext) -> None: + await _session_cmds.cmd_sessions_sub(self, ctx) + + async def _cmd_session_sub(self, ctx: CommandContext) -> None: + await _session_cmds.cmd_session_sub(self, ctx) + + async def _cmd_change(self, ctx: CommandContext) -> None: + await _session_cmds.cmd_change(self, ctx) + + async def _cmd_clear(self, ctx: CommandContext) -> None: + await _session_cmds.cmd_clear(self, ctx) + + # -- Agent commands (delegated to commands_agent) ---------------------- + + async def _cmd_skills(self, ctx: CommandContext) -> None: + await _agent_cmds.cmd_skills(self, ctx) + + async def _cmd_addskill(self, ctx: CommandContext) -> None: + await _agent_cmds.cmd_addskill(self, ctx) + + async def _cmd_removeskill(self, ctx: CommandContext) -> None: + await _agent_cmds.cmd_removeskill(self, ctx) + + async def _cmd_plugins(self, ctx: CommandContext) -> None: + await _agent_cmds.cmd_plugins(self, ctx) + + async def _cmd_plugin(self, ctx: CommandContext) -> None: + await _agent_cmds.cmd_plugin(self, ctx) + + async def _cmd_mcp(self, ctx: CommandContext) -> None: + await _agent_cmds.cmd_mcp(self, ctx) + + async def _cmd_schedules(self, ctx: CommandContext) -> None: + await _agent_cmds.cmd_schedules(self, ctx) + + async def _cmd_schedule(self, ctx: CommandContext) -> None: + await _agent_cmds.cmd_schedule(self, ctx) + + # -- System commands (delegated to commands_system) -------------------- + + async def _cmd_status(self, ctx: CommandContext) -> None: + await _system_cmds.cmd_status(self, ctx) + + async def _cmd_channels(self, ctx: CommandContext) -> None: + await _system_cmds.cmd_channels(self, ctx) + + async def _cmd_profile(self, ctx: CommandContext) -> None: + await _system_cmds.cmd_profile(self, ctx) + + async def _cmd_config(self, ctx: CommandContext) -> None: + await _system_cmds.cmd_config(self, ctx) + + async def _cmd_preflight(self, ctx: CommandContext) -> None: + await _system_cmds.cmd_preflight(self, ctx) + + async def _cmd_phone(self, ctx: CommandContext) -> None: + await _system_cmds.cmd_phone(self, ctx) + + async def _cmd_call(self, ctx: CommandContext) -> None: + await _system_cmds.cmd_call(self, ctx) + + async def _cmd_lockdown(self, ctx: CommandContext) -> None: + await _system_cmds.cmd_lockdown(self, ctx) + + async def _cmd_help(self, ctx: CommandContext) -> None: + await _system_cmds.cmd_help(self, ctx) diff --git a/app/runtime/messaging/commands/agent.py b/app/runtime/messaging/commands/agent.py new file mode 100644 index 0000000..dac03b1 --- /dev/null +++ b/app/runtime/messaging/commands/agent.py @@ -0,0 +1,191 @@ +"""Agent-related commands -- skills, plugins, MCP, schedules.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ...registries.plugins import get_plugin_registry +from ...registries.skills import get_registry as get_skill_registry +from ...scheduler import get_scheduler +from ...state.mcp_config import McpConfigStore + +if TYPE_CHECKING: + from ._dispatcher import CommandContext, CommandDispatcher + + +async def cmd_skills(dispatcher: CommandDispatcher, ctx: CommandContext) -> None: + from ...config.settings import cfg + + skills: list[str] = [] + if cfg.user_skills_dir.is_dir(): + for d in sorted(cfg.user_skills_dir.iterdir()): + if d.is_dir() and (d / "SKILL.md").exists(): + skills.append(d.name) + lines = [f"Skills ({len(skills)}):"] + [f" - {name}" for name in skills] + if not skills: + lines.append(" (none)") + await ctx.reply("\n".join(lines)) + + +async def cmd_addskill(dispatcher: CommandDispatcher, ctx: CommandContext) -> None: + parts = ctx.text.split(maxsplit=1) + if len(parts) < 2: + reg = get_skill_registry() + try: + catalog = await reg.fetch_catalog() + available = [s for s in catalog if not s.installed] + if available: + lines = [f"Available skills ({len(available)}):"] + for s in available: + desc = f" - {s.description}" if s.description else "" + lines.append(f" {s.name}{desc} [{s.source}]") + lines.append("\nUsage: /addskill ") + else: + lines = ["All catalog skills already installed.", "Usage: /addskill "] + except Exception as exc: + lines = [f"Failed to fetch catalog: {exc}", "Usage: /addskill "] + await ctx.reply("\n".join(lines)) + return + name = parts[1].strip() + reg = get_skill_registry() + await ctx.reply(f"Installing skill '{name}'...") + ok = await reg.install(name) + await ctx.reply(f"Skill '{name}' installed." if ok else f"Failed to install skill '{name}'.") + + +async def cmd_removeskill(dispatcher: CommandDispatcher, ctx: CommandContext) -> None: + parts = ctx.text.split(maxsplit=1) + if len(parts) < 2: + reg = get_skill_registry() + installed = reg.list_installed() + if installed: + lines = [f"Installed skills ({len(installed)}):"] + [f" {s.name}" for s in installed] + lines.append("\nUsage: /removeskill ") + else: + lines = ["No skills installed.", "Usage: /removeskill "] + await ctx.reply("\n".join(lines)) + return + name = parts[1].strip() + reg = get_skill_registry() + removed = reg.remove(name) + await ctx.reply(f"Skill '{name}' removed." if removed else f"Skill '{name}' not found.") + + +async def cmd_plugins(dispatcher: CommandDispatcher, ctx: CommandContext) -> None: + reg = get_plugin_registry() + plugins = reg.list_plugins() + if not plugins: + await ctx.reply("No plugins found.") + return + lines = [f"Plugins ({len(plugins)}):"] + for p in plugins: + icon = "+" if p.get("enabled") else "-" + desc = f" - {p['description']}" if p.get("description") else "" + lines.append(f" [{icon}] {p['id']}{desc} ({p.get('skill_count', 0)} skills)") + lines.append("\nUsage: /plugin enable , /plugin disable ") + await ctx.reply("\n".join(lines)) + + +async def cmd_plugin(dispatcher: CommandDispatcher, ctx: CommandContext) -> None: + parts = ctx.text.split() + if len(parts) < 3: + await ctx.reply("Usage: /plugin enable or /plugin disable ") + return + action, plugin_id = parts[1].lower(), parts[2].strip() + reg = get_plugin_registry() + if action == "enable": + result = reg.enable_plugin(plugin_id) + await ctx.reply(f"Plugin '{plugin_id}' enabled." if result else f"Plugin '{plugin_id}' not found.") + elif action == "disable": + result = reg.disable_plugin(plugin_id) + await ctx.reply(f"Plugin '{plugin_id}' disabled." if result else f"Plugin '{plugin_id}' not found.") + else: + await ctx.reply(f"Unknown action '{action}'.") + + +async def cmd_mcp(dispatcher: CommandDispatcher, ctx: CommandContext) -> None: + parts = ctx.text.split() + store = McpConfigStore() + if len(parts) == 1: + servers = store.list_servers() + if not servers: + await ctx.reply("No MCP servers configured.") + return + lines = [f"MCP Servers ({len(servers)}):"] + for s in servers: + icon = "+" if s.get("enabled") else "-" + builtin = " [builtin]" if s.get("builtin") else "" + lines.append(f" [{icon}] {s['name']} ({s.get('type', '?')}){builtin}") + if s.get("description"): + lines.append(f" {s['description']}") + await ctx.reply("\n".join(lines)) + return + + action = parts[1].lower() + if action == "add": + if len(parts) < 4: + await ctx.reply("Usage: /mcp add ") + return + try: + store.add_server(parts[2], "http", url=parts[3]) + await ctx.reply(f"MCP server '{parts[2]}' added. Start a /new session to activate.") + except ValueError as exc: + await ctx.reply(f"Error: {exc}") + elif action == "remove": + if len(parts) < 3: + await ctx.reply("Usage: /mcp remove ") + return + try: + ok = store.remove_server(parts[2]) + await ctx.reply(f"MCP server '{parts[2]}' removed." if ok else f"MCP server '{parts[2]}' not found.") + except ValueError as exc: + await ctx.reply(f"Error: {exc}") + elif action in ("enable", "disable"): + if len(parts) < 3: + await ctx.reply(f"Usage: /mcp {action} ") + return + ok = store.set_enabled(parts[2], action == "enable") + await ctx.reply(f"MCP server '{parts[2]}' {action}d." if ok else f"MCP server '{parts[2]}' not found.") + else: + await ctx.reply(f"Unknown MCP action '{action}'.") + + +async def cmd_schedules(dispatcher: CommandDispatcher, ctx: CommandContext) -> None: + sched = get_scheduler() + tasks = sched.list_tasks() + if not tasks: + await ctx.reply("No scheduled tasks.\n\nUsage: /schedule add ") + return + lines = [f"Scheduled Tasks ({len(tasks)}):"] + for t in tasks: + icon = "+" if t.enabled else "-" + schedule = t.cron or (f"once at {t.run_at}" if t.run_at else "?") + lines.append(f" [{icon}] {t.id} - {t.description}") + lines.append(f" Schedule: {schedule} | Last run: {t.last_run[:16] if t.last_run else 'never'}") + await ctx.reply("\n".join(lines)) + + +async def cmd_schedule(dispatcher: CommandDispatcher, ctx: CommandContext) -> None: + parts = ctx.text.split() + if len(parts) < 2: + await ctx.reply("Usage: /schedule add or /schedule remove ") + return + action = parts[1].lower() + sched = get_scheduler() + if action == "add": + if len(parts) < 8: + await ctx.reply("Usage: /schedule add ") + return + cron = " ".join(parts[2:7]) + prompt = " ".join(parts[7:]) + try: + task = sched.add(description=prompt[:60], prompt=prompt, cron=cron) + await ctx.reply(f"Scheduled task created:\n ID: {task.id}\n Cron: {cron}\n Prompt: {prompt}") + except ValueError as exc: + await ctx.reply(f"Error: {exc}") + elif action == "remove": + if len(parts) < 3: + await ctx.reply("Usage: /schedule remove ") + return + ok = sched.remove(parts[2]) + await ctx.reply(f"Task '{parts[2]}' removed." if ok else f"Task '{parts[2]}' not found.") diff --git a/app/runtime/messaging/commands/session.py b/app/runtime/messaging/commands/session.py new file mode 100644 index 0000000..c1f631d --- /dev/null +++ b/app/runtime/messaging/commands/session.py @@ -0,0 +1,130 @@ +"""Session and model management commands.""" + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +from ...config.settings import cfg + +if TYPE_CHECKING: + from ._dispatcher import CommandContext, CommandDispatcher + + +async def cmd_new(dispatcher: CommandDispatcher, ctx: CommandContext) -> None: + await dispatcher._agent.new_session() + if dispatcher._session_store: + dispatcher._session_store.start_session(uuid.uuid4().hex[:12], model=cfg.copilot_model) + await ctx.reply("New session started.") + + +async def cmd_model(dispatcher: CommandDispatcher, ctx: CommandContext) -> None: + parts = ctx.text.split(maxsplit=1) + if len(parts) < 2: + await ctx.reply(f"Current model: {cfg.copilot_model}\n\nUsage: /model ") + return + new_model = parts[1].strip() + old_model = cfg.copilot_model + cfg.write_env(COPILOT_MODEL=new_model) + await dispatcher._agent.new_session() + if dispatcher._session_store: + dispatcher._session_store.start_session(uuid.uuid4().hex[:12], model=new_model) + await ctx.reply(f"Model switched: {old_model} -> {new_model}\nNew session started.") + + +async def cmd_models(dispatcher: CommandDispatcher, ctx: CommandContext) -> None: + models = await dispatcher._agent.list_models() + if not models: + await ctx.reply("No models available.") + return + current = cfg.copilot_model + lines = ["Available Models", ""] + for m in models: + marker = " *" if m["id"] == current else "" + cost = f" ({m['billing_multiplier']}x)" if m.get("billing_multiplier", 1.0) != 1.0 else "" + reasoning = f" [reasoning: {', '.join(m['reasoning_efforts'])}]" if m.get("reasoning_efforts") else "" + policy = m.get("policy", "enabled") + if policy != "enabled": + lines.append(f" {m['id']}{marker}{cost} ({policy})") + else: + lines.append(f" {m['id']}{marker}{cost}{reasoning}") + lines.append(f"\nCurrent: {current}\nUse /model to switch.") + await ctx.reply("\n".join(lines)) + + +async def cmd_session(dispatcher: CommandDispatcher, ctx: CommandContext) -> None: + lines = [ + "Session Info", + f" Active: {'yes' if dispatcher._agent.has_session else 'no'}", + f" Model: {cfg.copilot_model}", + " Playwright MCP: enabled", + ] + await ctx.reply("\n".join(lines)) + + +async def cmd_sessions(dispatcher: CommandDispatcher, ctx: CommandContext) -> None: + if not dispatcher._session_store: + await ctx.reply("Session store not available.") + return + sessions = dispatcher._session_store.list_sessions() + if not sessions: + await ctx.reply("No recorded sessions.") + return + stats = dispatcher._session_store.get_session_stats() + lines = [f"Sessions ({stats['total_sessions']} total, {stats['total_messages']} messages)", ""] + for s in sessions[:10]: + started = s.get("started_at", "?")[:16] + lines.append(f" {s['id']} {started} {s.get('model', '?')} ({s.get('message_count', 0)} msgs)") + if len(sessions) > 10: + lines.append(f" ... and {len(sessions) - 10} more") + await ctx.reply("\n".join(lines)) + + +async def cmd_sessions_sub(dispatcher: CommandDispatcher, ctx: CommandContext) -> None: + parts = ctx.text.split() + if len(parts) >= 2 and parts[1].lower() == "clear": + if not dispatcher._session_store: + await ctx.reply("Session store not available.") + return + count = dispatcher._session_store.clear_all() + await ctx.reply(f"All sessions cleared ({count} deleted).") + else: + await cmd_sessions(dispatcher, ctx) + + +async def cmd_session_sub(dispatcher: CommandDispatcher, ctx: CommandContext) -> None: + parts = ctx.text.split() + if len(parts) >= 3 and parts[1].lower() == "delete": + if not dispatcher._session_store: + await ctx.reply("Session store not available.") + return + ok = dispatcher._session_store.delete_session(parts[2]) + await ctx.reply(f"Session '{parts[2]}' deleted." if ok else f"Session '{parts[2]}' not found.") + else: + await cmd_session(dispatcher, ctx) + + +async def cmd_change(dispatcher: CommandDispatcher, ctx: CommandContext) -> None: + if not dispatcher._session_store: + await ctx.reply("Session store not available.") + return + sessions = dispatcher._session_store.list_sessions() + if not sessions: + await ctx.reply("No sessions to switch to. Use /new to start one.") + return + lines = ["Recent Sessions:", ""] + for i, s in enumerate(sessions[:5], 1): + started = s.get("started_at", "?")[:16] + lines.append(f" {i}. {started} {s.get('model', '?')} ({s.get('message_count', 0)} msgs)") + lines.append(f" ID: {s['id']}") + await ctx.reply("\n".join(lines)) + + +async def cmd_clear(dispatcher: CommandDispatcher, ctx: CommandContext) -> None: + cleared = 0 + if cfg.memory_dir.is_dir(): + for f in cfg.memory_dir.rglob("*"): + if f.is_file(): + f.unlink() + cleared += 1 + await ctx.reply(f"Memory cleared ({cleared} files removed).") diff --git a/app/runtime/messaging/commands/system.py b/app/runtime/messaging/commands/system.py new file mode 100644 index 0000000..918b110 --- /dev/null +++ b/app/runtime/messaging/commands/system.py @@ -0,0 +1,206 @@ +"""System, status, and infrastructure commands.""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +from ...config.settings import cfg +from ...scheduler import get_scheduler +from ...state.profile import load_profile + +if TYPE_CHECKING: + from ._dispatcher import CommandContext, CommandDispatcher + +BOOT_TIME = time.monotonic() + + +async def cmd_status(dispatcher: CommandDispatcher, ctx: CommandContext) -> None: + uptime_seconds = int(time.monotonic() - BOOT_TIME) + hours, remainder = divmod(uptime_seconds, 3600) + minutes, seconds = divmod(remainder, 60) + + sched = get_scheduler() + tasks = sched.list_tasks() + active_tasks = [t for t in tasks if t.enabled] + total_reqs = sum(dispatcher._agent.request_counts.values()) + + lines = [ + "System Status", + f" Model: {cfg.copilot_model}", + f" Uptime: {hours}h {minutes}m {seconds}s", + f" Total requests: {total_reqs}", + ] + for model, count in sorted(dispatcher._agent.request_counts.items()): + lines.append(f" {model}: {count}") + if ctx.channel_ctx is not None: + channels = ctx.channel_ctx.connected_channels + lines.append(f" Connected channels: {', '.join(sorted(channels)) or 'none'}") + lines.append(f" Conversation refs: {ctx.channel_ctx.conversation_refs_count}") + lines.append(f" Scheduled tasks: {len(active_tasks)} active / {len(tasks)} total") + lines.append(f" Data dir: {cfg.data_dir}") + await ctx.reply("\n".join(lines)) + + +async def cmd_channels(dispatcher: CommandDispatcher, ctx: CommandContext) -> None: + lines = ["Channel Configuration\n"] + tg = dispatcher.infra.channels.telegram + if tg.token: + masked = tg.token[:8] + "..." + tg.token[-4:] if len(tg.token) > 12 else "***" + lines.append(f"Telegram:\n Token: {masked}\n Whitelist: {tg.whitelist or '(none)'}") + else: + lines.append("Telegram: not configured") + lines.append(f"\nBot Framework:\n App ID: {cfg.bot_app_id[:8] + '...' if cfg.bot_app_id else 'not set'}") + lines.append(f" Tenant: {cfg.bot_app_tenant_id[:8] + '...' if cfg.bot_app_tenant_id else 'not set'}") + lines.append(f" Admin secret: {'set' if cfg.admin_secret else 'not set'}") + if ctx.channel_ctx is not None: + refs = ctx.channel_ctx.conversation_refs + lines.append(f"\nActive Conversations ({len(refs)}):") + for r in refs: + user_name = r.user.name if r.user else "?" + lines.append(f" - {r.channel_id}: {user_name}") + await ctx.reply("\n".join(lines)) + + +async def cmd_profile(dispatcher: CommandDispatcher, ctx: CommandContext) -> None: + profile = load_profile() + lines = [ + "Agent Profile", + f" Name: {profile.get('name') or '(not set)'}", + f" Location: {profile.get('location') or '(not set)'}", + f" Emotional state: {profile.get('emotional_state', 'neutral')}", + ] + prefs = profile.get("preferences", {}) + if prefs: + lines.append(" Preferences:") + for k, v in prefs.items(): + lines.append(f" {k}: {v}") + await ctx.reply("\n".join(lines)) + + +async def cmd_config(dispatcher: CommandDispatcher, ctx: CommandContext) -> None: + parts = ctx.text.split(maxsplit=2) + if len(parts) == 1: + lines = [ + "Runtime Configuration", + f" Model: {cfg.copilot_model}", + f" Admin port: {cfg.admin_port}", + f" Bot port: {cfg.bot_port}", + f" Data dir: {cfg.data_dir}", + f" Admin secret: {'set' if cfg.admin_secret else 'not set'}", + "\nUsage: /config ", + ] + await ctx.reply("\n".join(lines)) + return + if len(parts) < 3: + await ctx.reply("Usage: /config ") + return + key = parts[1].upper() + allowed = {"COPILOT_MODEL", "ADMIN_PORT", "BOT_PORT", "VOICE_TARGET_NUMBER", "ACS_SOURCE_NUMBER"} + if key not in allowed: + await ctx.reply(f"Cannot set '{key}'. Allowed keys: {', '.join(sorted(allowed))}") + return + cfg.write_env(**{key: parts[2]}) + await ctx.reply(f"Config updated: {key} = {parts[2]}") + + +async def cmd_preflight(dispatcher: CommandDispatcher, ctx: CommandContext) -> None: + import aiohttp as _aiohttp + + base = f"http://127.0.0.1:{cfg.admin_port}" + headers = {"Authorization": f"Bearer {cfg.admin_secret}"} if cfg.admin_secret else {} + try: + async with _aiohttp.ClientSession() as session: + async with session.get(f"{base}/api/setup/preflight", headers=headers, timeout=_aiohttp.ClientTimeout(total=30)) as resp: + if resp.status != 200: + await ctx.reply(f"Preflight check failed (HTTP {resp.status}).") + return + data = await resp.json() + except Exception as exc: + await ctx.reply(f"Cannot reach preflight endpoint: {exc}") + return + + checks = data.get("checks", []) + lines = [f"Preflight Checks ({data.get('status', '?').upper()})"] + for c in checks: + icon = "OK" if c.get("ok") else "!!" + lines.append(f" [{icon}] {c['check']}: {c.get('detail', '')}") + await ctx.reply("\n".join(lines)) + + +async def cmd_phone(dispatcher: CommandDispatcher, ctx: CommandContext) -> None: + parts = ctx.text.split(maxsplit=1) + if len(parts) < 2: + await ctx.reply(f"Current target number: {cfg.voice_target_number or '(not set)'}\n\nUsage: /phone ") + return + number = parts[1].strip() + if not number.startswith("+"): + await ctx.reply("Phone number must start with + country code.") + return + cfg.write_env(VOICE_TARGET_NUMBER=number) + await ctx.reply(f"Voice target number set to {number}.") + + +async def cmd_call(dispatcher: CommandDispatcher, ctx: CommandContext) -> None: + import aiohttp as _aiohttp + + target = cfg.voice_target_number + if not target: + await ctx.reply("No target number configured. Use /phone first.") + return + base = f"http://127.0.0.1:{cfg.admin_port}" + headers = {"Authorization": f"Bearer {cfg.admin_secret}"} if cfg.admin_secret else {} + try: + async with _aiohttp.ClientSession() as session: + async with session.post(f"{base}/api/voice/call", json={"target_number": target}, headers=headers, timeout=_aiohttp.ClientTimeout(total=30)) as resp: + data = await resp.json() + if resp.status == 200: + await ctx.reply(f"Calling {target}...") + else: + await ctx.reply(f"Call failed: {data.get('error', f'HTTP {resp.status}')}") + except Exception as exc: + await ctx.reply(f"Call failed: {exc}") + + +async def cmd_lockdown(dispatcher: CommandDispatcher, ctx: CommandContext) -> None: + parts = ctx.text.split() + if len(parts) < 2: + state = "ENABLED" if cfg.lockdown_mode else "disabled" + await ctx.reply(f"Lock Down Mode: {state}\n\nUsage: /lockdown on | /lockdown off") + return + action = parts[1].lower() + if action not in ("on", "off"): + await ctx.reply("Usage: /lockdown on | /lockdown off") + return + if action == "on": + if cfg.lockdown_mode: + await ctx.reply("Lock Down Mode is already enabled.") + return + cfg.write_env(LOCKDOWN_MODE="1", TUNNEL_RESTRICTED="1") + from ...services.cloud.azure import AzureCLI + az = AzureCLI() + az.ok("logout") + az.invalidate_cache("account", "show") + await ctx.reply("Lock Down Mode ENABLED\n\n - Azure CLI logged out\n - Admin panel disabled") + else: + if not cfg.lockdown_mode: + await ctx.reply("Lock Down Mode is already disabled.") + return + cfg.write_env(LOCKDOWN_MODE="", TUNNEL_RESTRICTED="") + await ctx.reply("Lock Down Mode DISABLED\n\n - Admin panel re-enabled") + + +async def cmd_help(dispatcher: CommandDispatcher, ctx: CommandContext) -> None: + lines = [ + "Available Commands", + "", + " /new, /model , /models, /status, /session, /config", + " /skills, /addskill , /removeskill ", + " /plugins, /plugin enable|disable ", + " /mcp, /mcp add|remove|enable|disable ", + " /schedules, /schedule add|remove", + " /sessions, /session delete , /sessions clear", + " /change, /profile, /channels, /clear", + " /phone , /call, /preflight, /lockdown, /help", + ] + await ctx.reply("\n".join(lines)) diff --git a/app/runtime/messaging/message_processor.py b/app/runtime/messaging/message_processor.py index 13d9502..ab08992 100644 --- a/app/runtime/messaging/message_processor.py +++ b/app/runtime/messaging/message_processor.py @@ -62,14 +62,16 @@ async def process(self, ref: ConversationReference, prompt: str, channel: str) - async def bot_reply(text: str) -> None: await self._send_proactive_reply(ref, text, channel) - self._hitl.set_bot_reply_fn(bot_reply) - self._hitl.set_execution_context("bot_processor") - self._hitl.set_model(cfg.copilot_model) + self._hitl.bind_turn( + bot_reply_fn=bot_reply, + execution_context="bot_processor", + model=cfg.copilot_model, + ) try: response = await self._agent.send(prompt) finally: if self._hitl: - self._hitl.clear_bot_reply_fn() + self._hitl.unbind_turn() if response: self._memory.record("assistant", response) self.session_store.record("assistant", response) diff --git a/app/runtime/proactive_loop.py b/app/runtime/messaging/proactive_loop.py similarity index 96% rename from app/runtime/proactive_loop.py rename to app/runtime/messaging/proactive_loop.py index b3abe7c..d09579b 100644 --- a/app/runtime/proactive_loop.py +++ b/app/runtime/messaging/proactive_loop.py @@ -16,16 +16,16 @@ from pathlib import Path from typing import TYPE_CHECKING -from .config.settings import cfg -from .state.proactive import get_proactive_store -from .state.profile import log_interaction +from ..config.settings import cfg +from ..state.proactive import get_proactive_store +from ..state.profile import log_interaction if TYPE_CHECKING: - from .state.session_store import SessionStore + from ..state.session_store import SessionStore logger = logging.getLogger(__name__) -_TEMPLATES_DIR = Path(__file__).resolve().parent / "templates" +_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" # Minimum hours since last user activity before we proactively reach out. _MIN_USER_IDLE_HOURS = 1.0 @@ -94,9 +94,9 @@ def _gather_memory_context() -> str: def _gather_profile_context() -> str: """Read the user/agent profile JSON for LLM context.""" - from .state.profile import _profile_path + from ..state.profile import profile_path - path = _profile_path() + path = profile_path() if path.exists(): try: return path.read_text()[:1000] @@ -107,7 +107,7 @@ def _gather_profile_context() -> str: def _hours_since_last_session() -> float | None: """Return hours since the most recent session's last update.""" - from .state.session_store import SessionStore + from ..state.session_store import SessionStore store = SessionStore() sessions = store.list_sessions() @@ -137,7 +137,7 @@ async def _generate_proactive_message() -> str | None: Returns the message string or ``None`` if the LLM decided nothing is worth sending (``NO_FOLLOWUP``). """ - from .agent.one_shot import run_one_shot + from ..agent.one_shot import run_one_shot template = (_TEMPLATES_DIR / "proactive_generate_prompt.md").read_text() store = get_proactive_store() @@ -298,7 +298,7 @@ async def _deliver_message( session_store: "SessionStore | None", ) -> None: """Attempt to deliver a pending proactive message.""" - from .state.proactive import PendingMessage # noqa: F811 + from ..state.proactive import PendingMessage # noqa: F811 now = datetime.now(UTC) logger.info( diff --git a/app/runtime/realtime/__init__.py b/app/runtime/realtime/__init__.py index 25fe62e..8d8d152 100644 --- a/app/runtime/realtime/__init__.py +++ b/app/runtime/realtime/__init__.py @@ -1,5 +1,7 @@ """Realtime voice call module -- ACS + OpenAI Realtime API integration.""" +from __future__ import annotations + from .caller import AcsCaller from .middleware import RealtimeMiddleTier from .routes import RealtimeRoutes diff --git a/app/runtime/realtime/auth.py b/app/runtime/realtime/auth.py index 154104f..f5a903e 100644 --- a/app/runtime/realtime/auth.py +++ b/app/runtime/realtime/auth.py @@ -15,6 +15,8 @@ from aiohttp import web +from ..util.singletons import register_singleton + logger = logging.getLogger(__name__) _ACS_ISSUER = "https://acscallautomation.communication.azure.com" @@ -38,6 +40,16 @@ def get_learned_audience() -> str: return _learned_audience +def _reset_learned_audience() -> None: + """Clear the auto-learned audience (for test isolation).""" + global _learned_audience + with _audience_lock: + _learned_audience = "" + + +register_singleton(_reset_learned_audience) + + def validate_token_param(request: web.Request, expected_token: str) -> bool: return request.query.get("token", "") == expected_token diff --git a/app/runtime/realtime/caller.py b/app/runtime/realtime/caller.py index 552ea53..7356be3 100644 --- a/app/runtime/realtime/caller.py +++ b/app/runtime/realtime/caller.py @@ -58,16 +58,30 @@ def _ensure_client(self) -> Any: self._client = CallAutomationClient.from_connection_string(self.acs_connection_string) return self._client - async def initiate_call(self, target_number: str) -> None: + @staticmethod + def _build_media_config(ws_url: str) -> Any: + """Build the shared ``MediaStreamingOptions`` for ACS calls.""" from azure.communication.callautomation import ( AudioFormat, MediaStreamingAudioChannelType, MediaStreamingContentType, MediaStreamingOptions, - PhoneNumberIdentifier, StreamingTransportType, ) + return MediaStreamingOptions( + transport_url=ws_url, + transport_type=StreamingTransportType.WEBSOCKET, + content_type=MediaStreamingContentType.AUDIO, + audio_channel_type=MediaStreamingAudioChannelType.MIXED, + start_media_streaming=True, + enable_bidirectional=True, + audio_format=AudioFormat.PCM24_K_MONO, + ) + + async def initiate_call(self, target_number: str) -> None: + from azure.communication.callautomation import PhoneNumberIdentifier + callback_url = self.acs_callback_path ws_url = self.acs_media_streaming_websocket_path if not callback_url or not callback_url.startswith("https://"): @@ -78,16 +92,7 @@ async def initiate_call(self, target_number: str) -> None: client = self._ensure_client() target = PhoneNumberIdentifier(target_number) source = PhoneNumberIdentifier(self.source_number) - - media_config = MediaStreamingOptions( - transport_url=ws_url, - transport_type=StreamingTransportType.WEBSOCKET, - content_type=MediaStreamingContentType.AUDIO, - audio_channel_type=MediaStreamingAudioChannelType.MIXED, - start_media_streaming=True, - enable_bidirectional=True, - audio_format=AudioFormat.PCM24_K_MONO, - ) + media_config = self._build_media_config(ws_url) logger.info( "Initiating outbound call: target=%s, source=%s, callback=%s, ws=%s", @@ -108,24 +113,8 @@ async def initiate_call(self, target_number: str) -> None: raise async def answer_inbound_call(self, incoming_call_context: str) -> None: - from azure.communication.callautomation import ( - AudioFormat, - MediaStreamingAudioChannelType, - MediaStreamingContentType, - MediaStreamingOptions, - StreamingTransportType, - ) - client = self._ensure_client() - media_config = MediaStreamingOptions( - transport_url=self.acs_media_streaming_websocket_path, - transport_type=StreamingTransportType.WEBSOCKET, - content_type=MediaStreamingContentType.AUDIO, - audio_channel_type=MediaStreamingAudioChannelType.MIXED, - start_media_streaming=True, - enable_bidirectional=True, - audio_format=AudioFormat.PCM24_K_MONO, - ) + media_config = self._build_media_config(self.acs_media_streaming_websocket_path) logger.info("Answering inbound call") client.answer_call(incoming_call_context, self.acs_callback_path, media_streaming=media_config) logger.info("Inbound call answered") diff --git a/app/runtime/realtime/middleware.py b/app/runtime/realtime/middleware.py index f71c139..453e56f 100644 --- a/app/runtime/realtime/middleware.py +++ b/app/runtime/realtime/middleware.py @@ -5,7 +5,6 @@ import asyncio import json import logging -from pathlib import Path from typing import Any import aiohttp @@ -13,7 +12,7 @@ from azure.core.credentials import AzureKeyCredential from azure.identity import DefaultAzureCredential, get_bearer_token_provider -from .prompt import REALTIME_SYSTEM_PROMPT +from .prompt import REALTIME_SYSTEM_PROMPT, TEMPLATES_DIR from .tools import ( ALL_REALTIME_TOOL_SCHEMAS, handle_check_agent_task, @@ -23,8 +22,6 @@ logger = logging.getLogger(__name__) -_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" - class RealtimeMiddleTier: """Proxies WebSocket traffic between a client and the OpenAI Realtime API.""" @@ -125,10 +122,10 @@ def _consume_pending(self) -> tuple[str, list[dict[str, Any]]]: else: parts: list[str] = [base] if prompt: - template = (_TEMPLATES_DIR / "realtime_call_instructions.md").read_text() + template = (TEMPLATES_DIR / "realtime_call_instructions.md").read_text() parts.append(template.format(prompt=prompt)) if opening_message: - template = (_TEMPLATES_DIR / "realtime_opening_message.md").read_text() + template = (TEMPLATES_DIR / "realtime_opening_message.md").read_text() parts.append(template.format(opening_message=opening_message)) effective_prompt = "\n\n".join(parts) if len(parts) > 1 else base @@ -280,12 +277,9 @@ async def _execute_tool(self, item: dict[str, Any], server_ws: ClientWebSocketRe logger.info("Realtime tool call: %s(%s)", name, args_str[:200]) - if name == "invoke_agent": - result = await handle_invoke_agent(args, self.agent) - elif name == "invoke_agent_async": - result = await handle_invoke_agent_async(args, self.agent) - elif name == "check_agent_task": - result = await handle_check_agent_task(args) + handler = _TOOL_DISPATCH.get(name) + if handler: + result = await handler(args, self.agent) else: result = f"Unknown tool: {name}" @@ -302,6 +296,21 @@ def _auth_headers(self) -> dict[str, str]: raise ValueError("No authentication configured for OpenAI Realtime") +# -- tool dispatch table --------------------------------------------------- + + +async def _dispatch_check_agent_task(args: dict[str, Any], agent: Any) -> str: + """Thin adapter so check_agent_task matches the ``(args, agent)`` signature.""" + return await handle_check_agent_task(args) + + +_TOOL_DISPATCH: dict[str, Any] = { + "invoke_agent": handle_invoke_agent, + "invoke_agent_async": handle_invoke_agent_async, + "check_agent_task": _dispatch_check_agent_task, +} + + class _ToolCall: __slots__ = ("call_id", "previous_id") diff --git a/app/runtime/realtime/prompt.py b/app/runtime/realtime/prompt.py index 578d941..e295271 100644 --- a/app/runtime/realtime/prompt.py +++ b/app/runtime/realtime/prompt.py @@ -1,7 +1,9 @@ -"""System prompt for the Realtime voice model.""" +"""System prompt and template directory for the Realtime voice model.""" + +from __future__ import annotations from pathlib import Path -_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" +TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" -REALTIME_SYSTEM_PROMPT: str = (_TEMPLATES_DIR / "realtime_prompt.md").read_text() +REALTIME_SYSTEM_PROMPT: str = (TEMPLATES_DIR / "realtime_prompt.md").read_text() diff --git a/app/runtime/realtime/tools.py b/app/runtime/realtime/tools.py index 80406f0..cc9e87c 100644 --- a/app/runtime/realtime/tools.py +++ b/app/runtime/realtime/tools.py @@ -13,6 +13,7 @@ from typing import Any from ..config.settings import cfg +from ..util.singletons import register_singleton logger = logging.getLogger(__name__) @@ -81,6 +82,9 @@ def _reset_task_store() -> None: _task_store = None +register_singleton(_reset_task_store) + + INVOKE_AGENT_SCHEMA = { "type": "function", "name": "invoke_agent", @@ -239,12 +243,11 @@ def _make_realtime_hook( guardrails policies are respected during voice-initiated tasks. """ from ..agent.hitl import HitlInterceptor - from ..state.guardrails_config import get_guardrails_config + from ..state.guardrails.config import get_guardrails_config store = get_guardrails_config() interceptor = HitlInterceptor(store) - interceptor.set_execution_context("realtime") - interceptor.set_model(_REALTIME_MODEL) + interceptor.bind_turn(execution_context="realtime", model=_REALTIME_MODEL) # Forward AITL / Prompt Shield / phone from the shared interceptor. shared_hitl = getattr(agent, "hitl_interceptor", None) diff --git a/app/runtime/registries/__init__.py b/app/runtime/registries/__init__.py index f79f857..5eed2e5 100644 --- a/app/runtime/registries/__init__.py +++ b/app/runtime/registries/__init__.py @@ -1,5 +1,10 @@ """Plugin and skill registries.""" +from __future__ import annotations + +from .plugins import PluginManifest, PluginRegistry, get_plugin_registry +from .skills import SkillInfo, SkillRegistry, get_registry + __all__ = [ "PluginManifest", "PluginRegistry", diff --git a/app/runtime/registries/catalog.py b/app/runtime/registries/catalog.py new file mode 100644 index 0000000..db95aaa --- /dev/null +++ b/app/runtime/registries/catalog.py @@ -0,0 +1,287 @@ +"""GitHub skill catalog -- fetch remote skill listings and install them.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +import shutil +from pathlib import Path +from typing import Any + +import aiohttp + +from ..config.settings import cfg + +logger = logging.getLogger(__name__) + +_CATALOG_SOURCES: list[dict[str, str]] = [ + { + "owner": "github", + "repo": "awesome-copilot", + "path": "skills", + "branch": "main", + "label": "GitHub Awesome Copilot", + "category": "github-awesome", + }, + { + "owner": "anthropics", + "repo": "skills", + "path": "skills", + "branch": "main", + "label": "Anthropic Skills", + "category": "anthropic", + }, +] + +_GITHUB_API = "https://api.github.com" +_GITHUB_RAW = "https://raw.githubusercontent.com" +_ORIGIN_FILE = ".origin" + + +def _github_headers() -> dict[str, str]: + """Build common headers for GitHub API requests.""" + headers: dict[str, str] = { + "Accept": "application/vnd.github.v3+json", + "User-Agent": "polyclaw-skill-registry", + } + token = cfg.github_token + if token: + headers["Authorization"] = f"token {token}" + return headers + + +async def fetch_catalog( + installed_names: set[str], + parse_frontmatter: Any, + curated_skills: set[str], +) -> tuple[list[Any], bool, int | None]: + """Fetch remote skill catalog from all configured GitHub sources. + + Returns ``(skills, rate_limited, rate_limit_reset)``. + """ + from .skills import SkillInfo + + rate_limited = False + rate_limit_reset: int | None = None + + headers = _github_headers() + all_skills: list[SkillInfo] = [] + + async with aiohttp.ClientSession(headers=headers) as session: + tasks = [ + _fetch_source(session, src, installed_names, parse_frontmatter, curated_skills) + for src in _CATALOG_SOURCES + ] + results = await asyncio.gather(*tasks, return_exceptions=True) + + for i, res in enumerate(results): + if isinstance(res, list): + all_skills.extend(res) + elif isinstance(res, _RateLimited): + rate_limited = True + rate_limit_reset = res.reset_at + elif isinstance(res, Exception): + logger.error("Catalog source %s failed: %s", _CATALOG_SOURCES[i]["label"], res) + + try: + await _fetch_commit_counts(all_skills) + except Exception: + pass + + return all_skills, rate_limited, rate_limit_reset + + +class _RateLimited(Exception): + """Raised internally when GitHub returns a 403 rate-limit response.""" + + def __init__(self, reset_at: int | None = None) -> None: + self.reset_at = reset_at + + +async def _fetch_source( + session: aiohttp.ClientSession, + src: dict[str, str], + installed_names: set[str], + parse_frontmatter: Any, + curated_skills: set[str], +) -> list[Any]: + from .skills import SkillInfo + + url = ( + f"{_GITHUB_API}/repos/{src['owner']}/{src['repo']}" + f"/contents/{src['path']}?ref={src['branch']}" + ) + try: + async with session.get(url) as resp: + if resp.status != 200: + remaining = resp.headers.get("X-RateLimit-Remaining", "?") + if resp.status == 403 and remaining == "0": + reset_at: int | None = None + try: + reset_at = int(resp.headers.get("X-RateLimit-Reset", "0")) + except (ValueError, TypeError): + pass + raise _RateLimited(reset_at) + return [] + entries = await resp.json() + except _RateLimited: + raise + except Exception as exc: + logger.error("GitHub API request failed: %s", exc) + return [] + + if not isinstance(entries, list): + return [] + + sem = asyncio.Semaphore(20) + + async def _get_skill(name: str) -> SkillInfo | None: + async with sem: + raw_url = ( + f"{_GITHUB_RAW}/{src['owner']}/{src['repo']}" + f"/{src['branch']}/{src['path']}/{name}/SKILL.md" + ) + try: + async with session.get(raw_url) as r: + fm = parse_frontmatter(await r.text()) if r.status == 200 else {} + except Exception: + fm = {} + skill_name = fm.get("name", name) + return SkillInfo( + name=skill_name, + verb=fm.get("verb", name), + description=fm.get("description", ""), + source=src["label"], + category=src.get("category", ""), + repo_owner=src["owner"], + repo_name=src["repo"], + repo_path=f"{src['path']}/{name}", + repo_branch=src["branch"], + installed=skill_name in installed_names, + recommended=skill_name in curated_skills, + ) + + results = await asyncio.gather( + *[_get_skill(e["name"]) for e in entries if e.get("type") == "dir"], + return_exceptions=True, + ) + return [r for r in results if isinstance(r, SkillInfo)] + + +async def _fetch_commit_counts(skills: list[Any]) -> None: + headers = _github_headers() + sem = asyncio.Semaphore(10) + + async def _get_count(session: aiohttp.ClientSession, skill: Any) -> None: + if not skill.repo_owner: + return + async with sem: + url = ( + f"{_GITHUB_API}/repos/{skill.repo_owner}/{skill.repo_name}" + f"/commits?path={skill.repo_path}&sha={skill.repo_branch}&per_page=1" + ) + try: + async with session.get(url) as resp: + if resp.status != 200: + return + link = resp.headers.get("Link", "") + match = re.search(r'page=(\d+)>; rel="last"', link) + if match: + skill.edit_count = int(match.group(1)) + else: + data = await resp.json() + skill.edit_count = len(data) if isinstance(data, list) else 0 + except Exception: + pass + + async with aiohttp.ClientSession(headers=headers) as session: + await asyncio.gather( + *[_get_count(session, s) for s in skills], return_exceptions=True + ) + + +async def install_from_catalog( + skill: Any, + target_dir: Path, +) -> str | None: + """Download a skill from GitHub into *target_dir*. + + Returns ``None`` on success, or an error message string. + """ + headers = _github_headers() + + try: + async with aiohttp.ClientSession(headers=headers) as session: + await _download_dir( + session, + owner=skill.repo_owner, + repo=skill.repo_name, + path=skill.repo_path, + branch=skill.repo_branch, + target=target_dir, + ) + except Exception as exc: + if target_dir.exists(): + shutil.rmtree(target_dir) + return f"Download failed for skill {skill.name!r}: {exc}" + + origin_path = target_dir / _ORIGIN_FILE + origin_path.write_text( + json.dumps( + { + "origin": "marketplace", + "source": skill.source, + "category": skill.category, + "repo_owner": skill.repo_owner, + "repo_name": skill.repo_name, + "repo_path": skill.repo_path, + }, + indent=2, + ) + + "\n" + ) + return None + + +async def _download_dir( + session: aiohttp.ClientSession, + *, + owner: str, + repo: str, + path: str, + branch: str, + target: Path, +) -> None: + """Recursively download a directory from a GitHub repo.""" + url = f"{_GITHUB_API}/repos/{owner}/{repo}/contents/{path}?ref={branch}" + async with session.get(url) as resp: + if resp.status != 200: + body = await resp.text() + raise RuntimeError(f"GitHub API HTTP {resp.status} for {url}: {body[:500]}") + entries = await resp.json() + + if not isinstance(entries, list): + entries = [entries] + + for entry in entries: + if entry["type"] == "file": + raw_url = ( + entry.get("download_url") + or f"{_GITHUB_RAW}/{owner}/{repo}/{branch}/{entry['path']}" + ) + async with session.get(raw_url) as file_resp: + if file_resp.status == 200: + (target / entry["name"]).write_bytes(await file_resp.read()) + elif entry["type"] == "dir": + sub_dir = target / entry["name"] + sub_dir.mkdir(parents=True, exist_ok=True) + await _download_dir( + session, + owner=owner, + repo=repo, + path=entry["path"], + branch=branch, + target=sub_dir, + ) diff --git a/app/runtime/registries/skills.py b/app/runtime/registries/skills.py index 455d122..454cf13 100644 --- a/app/runtime/registries/skills.py +++ b/app/runtime/registries/skills.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import json import logging import re @@ -11,35 +10,11 @@ from pathlib import Path from typing import Any -import aiohttp - from ..config.settings import cfg from ..util.singletons import register_singleton logger = logging.getLogger(__name__) -_CATALOG_SOURCES: list[dict[str, str]] = [ - { - "owner": "github", - "repo": "awesome-copilot", - "path": "skills", - "branch": "main", - "label": "GitHub Awesome Copilot", - "category": "github-awesome", - }, - { - "owner": "anthropics", - "repo": "skills", - "path": "skills", - "branch": "main", - "label": "Anthropic Skills", - "category": "anthropic", - }, -] - -_GITHUB_API = "https://api.github.com" -_GITHUB_RAW = "https://raw.githubusercontent.com" -_CATALOG_CACHE_TTL = 300 _CURATED_SKILLS: set[str] = {"web-search", "summarize-url", "daily-briefing"} _ORIGIN_FILE = ".origin" @@ -200,11 +175,13 @@ def remove(self, name: str) -> bool: async def fetch_catalog(self, *, force: bool = False) -> list[SkillInfo]: import time + from .catalog import fetch_catalog as _fetch_catalog + now = time.monotonic() if ( not force and self._catalog_cache is not None - and (now - self._catalog_ts) < _CATALOG_CACHE_TTL + and (now - self._catalog_ts) < 300 ): return self._catalog_cache @@ -212,137 +189,19 @@ async def fetch_catalog(self, *, force: bool = False) -> list[SkillInfo]: self.rate_limited = False self.rate_limit_reset = None - headers: dict[str, str] = { - "Accept": "application/vnd.github.v3+json", - "User-Agent": "polyclaw-skill-registry", - } - token = cfg.github_token - if token: - headers["Authorization"] = f"token {token}" - - all_skills: list[SkillInfo] = [] - async with aiohttp.ClientSession(headers=headers) as session: - tasks = [self._fetch_source(session, src, installed_names) for src in _CATALOG_SOURCES] - results = await asyncio.gather(*tasks, return_exceptions=True) - - for i, res in enumerate(results): - if isinstance(res, list): - all_skills.extend(res) - elif isinstance(res, Exception): - logger.error("Catalog source %s failed: %s", _CATALOG_SOURCES[i]["label"], res) - - try: - await self._fetch_commit_counts(all_skills) - except Exception: - pass + all_skills, rate_limited, rate_limit_reset = await _fetch_catalog( + installed_names, _parse_frontmatter, _CURATED_SKILLS, + ) + self.rate_limited = rate_limited + self.rate_limit_reset = rate_limit_reset self._catalog_cache = all_skills self._catalog_ts = now return all_skills - async def _fetch_source( - self, - session: aiohttp.ClientSession, - src: dict[str, str], - installed_names: set[str], - ) -> list[SkillInfo]: - url = ( - f"{_GITHUB_API}/repos/{src['owner']}/{src['repo']}" - f"/contents/{src['path']}?ref={src['branch']}" - ) - try: - async with session.get(url) as resp: - if resp.status != 200: - remaining = resp.headers.get("X-RateLimit-Remaining", "?") - if resp.status == 403 and remaining == "0": - self.rate_limited = True - try: - self.rate_limit_reset = int( - resp.headers.get("X-RateLimit-Reset", "0") - ) - except (ValueError, TypeError): - pass - return [] - entries = await resp.json() - except Exception as exc: - logger.error("GitHub API request failed: %s", exc) - return [] - - if not isinstance(entries, list): - return [] - - sem = asyncio.Semaphore(20) - - async def _get_skill(name: str) -> SkillInfo | None: - async with sem: - raw_url = ( - f"{_GITHUB_RAW}/{src['owner']}/{src['repo']}" - f"/{src['branch']}/{src['path']}/{name}/SKILL.md" - ) - try: - async with session.get(raw_url) as r: - fm = _parse_frontmatter(await r.text()) if r.status == 200 else {} - except Exception: - fm = {} - skill_name = fm.get("name", name) - return SkillInfo( - name=skill_name, - verb=fm.get("verb", name), - description=fm.get("description", ""), - source=src["label"], - category=src.get("category", ""), - repo_owner=src["owner"], - repo_name=src["repo"], - repo_path=f"{src['path']}/{name}", - repo_branch=src["branch"], - installed=skill_name in installed_names, - recommended=skill_name in _CURATED_SKILLS, - ) - - results = await asyncio.gather( - *[_get_skill(e["name"]) for e in entries if e.get("type") == "dir"], - return_exceptions=True, - ) - return [r for r in results if isinstance(r, SkillInfo)] - - async def _fetch_commit_counts(self, skills: list[SkillInfo]) -> None: - headers: dict[str, str] = { - "Accept": "application/vnd.github.v3+json", - "User-Agent": "polyclaw-skill-registry", - } - token = cfg.github_token - if token: - headers["Authorization"] = f"token {token}" - sem = asyncio.Semaphore(10) - - async def _get_count(session: aiohttp.ClientSession, skill: SkillInfo) -> None: - if not skill.repo_owner: - return - async with sem: - url = ( - f"{_GITHUB_API}/repos/{skill.repo_owner}/{skill.repo_name}" - f"/commits?path={skill.repo_path}&sha={skill.repo_branch}&per_page=1" - ) - try: - async with session.get(url) as resp: - if resp.status != 200: - return - link = resp.headers.get("Link", "") - match = re.search(r'page=(\d+)>; rel="last"', link) - if match: - skill.edit_count = int(match.group(1)) - else: - data = await resp.json() - skill.edit_count = len(data) if isinstance(data, list) else 0 - except Exception: - pass - - async with aiohttp.ClientSession(headers=headers) as session: - await asyncio.gather( - *[_get_count(session, s) for s in skills], return_exceptions=True - ) - async def install(self, name: str) -> str | None: + from .catalog import install_from_catalog + catalog = await self.fetch_catalog() skill = next((s for s in catalog if s.name == name), None) if not skill: @@ -353,90 +212,14 @@ async def install(self, name: str) -> str | None: target_dir = cfg.user_skills_dir / name target_dir.mkdir(parents=True, exist_ok=True) - headers: dict[str, str] = { - "Accept": "application/vnd.github.v3+json", - "User-Agent": "polyclaw-skill-registry", - } - token = cfg.github_token - if token: - headers["Authorization"] = f"token {token}" - - try: - async with aiohttp.ClientSession(headers=headers) as session: - await self._download_dir( - session, - owner=skill.repo_owner, - repo=skill.repo_name, - path=skill.repo_path, - branch=skill.repo_branch, - target=target_dir, - ) - except Exception as exc: - if target_dir.exists(): - shutil.rmtree(target_dir) - return f"Download failed for skill {name!r}: {exc}" - - origin_path = target_dir / _ORIGIN_FILE - origin_path.write_text( - json.dumps( - { - "origin": "marketplace", - "source": skill.source, - "category": skill.category, - "repo_owner": skill.repo_owner, - "repo_name": skill.repo_name, - "repo_path": skill.repo_path, - }, - indent=2, - ) - + "\n" - ) + error = await install_from_catalog(skill, target_dir) + if error: + return error self._catalog_cache = None logger.info("Installed skill: %s -> %s", name, target_dir) return None - async def _download_dir( - self, - session: aiohttp.ClientSession, - *, - owner: str, - repo: str, - path: str, - branch: str, - target: Path, - ) -> None: - url = f"{_GITHUB_API}/repos/{owner}/{repo}/contents/{path}?ref={branch}" - async with session.get(url) as resp: - if resp.status != 200: - body = await resp.text() - raise RuntimeError(f"GitHub API HTTP {resp.status} for {url}: {body[:500]}") - entries = await resp.json() - - if not isinstance(entries, list): - entries = [entries] - - for entry in entries: - if entry["type"] == "file": - raw_url = ( - entry.get("download_url") - or f"{_GITHUB_RAW}/{owner}/{repo}/{branch}/{entry['path']}" - ) - async with session.get(raw_url) as file_resp: - if file_resp.status == 200: - (target / entry["name"]).write_bytes(await file_resp.read()) - elif entry["type"] == "dir": - sub_dir = target / entry["name"] - sub_dir.mkdir(parents=True, exist_ok=True) - await self._download_dir( - session, - owner=owner, - repo=repo, - path=entry["path"], - branch=branch, - target=sub_dir, - ) - _registry: SkillRegistry | None = None diff --git a/app/runtime/sandbox/__init__.py b/app/runtime/sandbox/__init__.py new file mode 100644 index 0000000..2ade41b --- /dev/null +++ b/app/runtime/sandbox/__init__.py @@ -0,0 +1,20 @@ +"""Agent sandbox executor -- runs agent commands in ACA Dynamic Sessions. + +.. warning:: This feature is experimental and may change or be removed in + future releases. +""" + +from __future__ import annotations + +from .executor import SandboxExecutor +from .helpers import _build_replay_command, _extract_command, _is_shell_tool, _parse_tool_args +from .interceptor import SandboxToolInterceptor + +__all__ = [ + "SandboxExecutor", + "SandboxToolInterceptor", + "_build_replay_command", + "_extract_command", + "_is_shell_tool", + "_parse_tool_args", +] diff --git a/app/runtime/sandbox.py b/app/runtime/sandbox/executor.py similarity index 67% rename from app/runtime/sandbox.py rename to app/runtime/sandbox/executor.py index 2adfbdf..b9be188 100644 --- a/app/runtime/sandbox.py +++ b/app/runtime/sandbox/executor.py @@ -1,8 +1,4 @@ -"""Agent sandbox executor -- runs agent commands in ACA Dynamic Sessions. - -.. warning:: This feature is experimental and may change or be removed in - future releases. -""" +"""Sandbox executor -- runs agent commands in ACA Dynamic Sessions.""" from __future__ import annotations @@ -12,7 +8,6 @@ import json import logging import os -import shlex import shutil import time import uuid @@ -22,8 +17,8 @@ import aiohttp -from .config.settings import cfg -from .state.sandbox_config import SandboxConfigStore +from ..config.settings import cfg +from ..state.sandbox_config import SandboxConfigStore logger = logging.getLogger(__name__) @@ -31,8 +26,6 @@ TOKEN_SCOPE = "https://dynamicsessions.io/.default" MAX_ZIP_SIZE = 100 * 1024 * 1024 -_SHELL_TOOL_PATTERNS = ("terminal", "shell", "bash", "command") -_SESSION_IDLE_TIMEOUT = 60 _UPLOAD_MAX_RETRIES = 3 _UPLOAD_BACKOFF_BASE = 1.0 @@ -87,15 +80,18 @@ async def execute( return self._result(False, f"Failed to create code archive: {exc}", start, session_id) if data_zip: - if not await self._upload_bytes(http, endpoint, session_id, "agent_data.zip", data_zip, headers): - return self._result(False, "Data upload failed", start, session_id) + err = await self._upload_bytes(http, endpoint, session_id, "agent_data.zip", data_zip, headers) + if err: + return self._result(False, f"Data upload failed: {err}", start, session_id) - if not await self._upload_bytes(http, endpoint, session_id, "polyclaw_code.zip", code_zip, headers): - return self._result(False, "Code upload failed", start, session_id) + err = await self._upload_bytes(http, endpoint, session_id, "polyclaw_code.zip", code_zip, headers) + if err: + return self._result(False, f"Code upload failed: {err}", start, session_id) bootstrap = self._build_bootstrap_script(command, has_data=data_zip is not None, env_vars=env_vars) - if not await self._upload_bytes(http, endpoint, session_id, "bootstrap.sh", bootstrap.encode(), headers): - return self._result(False, "Bootstrap upload failed", start, session_id) + err = await self._upload_bytes(http, endpoint, session_id, "bootstrap.sh", bootstrap.encode(), headers) + if err: + return self._result(False, f"Bootstrap upload failed: {err}", start, session_id) exec_result = await self._execute_in_session(http, endpoint, session_id, headers, timeout) if not exec_result["success"]: @@ -261,8 +257,14 @@ async def _get_token(self) -> str: async def _upload_bytes( self, http: aiohttp.ClientSession, endpoint: str, session_id: str, filename: str, data: bytes, headers: dict[str, str], - ) -> bool: + ) -> str: + """Upload bytes to the session. Returns empty string on success, error detail on failure.""" url = f"{endpoint}/files/upload?api-version={API_VERSION}&identifier={session_id}" + size_kb = len(data) / 1024 + logger.info( + "[sandbox.upload] file=%s size=%.1fKB session=%s", + filename, size_kb, session_id, + ) last_error = "" for attempt in range(_UPLOAD_MAX_RETRIES): form = aiohttp.FormData() @@ -275,7 +277,7 @@ async def _upload_bytes( timeout=aiohttp.ClientTimeout(total=120), ) as resp: if resp.status in (200, 201, 202): - return True + return "" body = await resp.text() last_error = f"HTTP {resp.status}: {body[:300]}" logger.warning( @@ -295,7 +297,7 @@ async def _upload_bytes( "Upload %s failed after %d attempts: %s", filename, _UPLOAD_MAX_RETRIES, last_error, ) - return False + return last_error async def _execute_in_session( self, http: aiohttp.ClientSession, endpoint: str, session_id: str, @@ -325,15 +327,10 @@ async def _execute_in_session( return {"success": False, "error": f"Execution failed: {resp.status} {text[:300]}"} result = await resp.json() props = result.get("properties", {}) - raw_stdout = props.get("stdout", "") - try: - output = json.loads(raw_stdout.strip()) - stdout, stderr, rc = output.get("stdout", ""), output.get("stderr", ""), output.get("rc", 0) - except (json.JSONDecodeError, AttributeError, TypeError): - stdout, stderr, rc = raw_stdout, props.get("stderr", ""), 1 - if rc != 0: - return {"success": False, "stdout": stdout, "stderr": stderr, "error": stderr or f"Exit code {rc}"} - return {"success": True, "stdout": stdout, "stderr": stderr} + return self._parse_exec_result( + props.get("stdout", ""), + fallback_stderr=props.get("stderr", ""), + ) except Exception as exc: logger.error("Sandbox exec exception: %s", exc, exc_info=True) return {"success": False, "error": str(exc)} @@ -353,20 +350,29 @@ async def provision_session(self, session_id: str) -> dict[str, Any]: headers = {"Authorization": f"Bearer {token}"} data_zip = self._create_data_zip() if self._store.sync_data else None + has_data = False if data_zip: - if not await self._upload_bytes(http, endpoint, session_id, "agent_data.zip", data_zip, headers): - return self._result(False, "Data upload failed", start, session_id) + err = await self._upload_bytes(http, endpoint, session_id, "agent_data.zip", data_zip, headers) + if err: + logger.warning( + "[sandbox.provision] Data upload failed (non-fatal), " + "continuing without data sync: %s", err, + ) + else: + has_data = True try: code_zip = self._create_code_zip() except Exception as exc: return self._result(False, f"Code archive failed: {exc}", start, session_id) - if not await self._upload_bytes(http, endpoint, session_id, "polyclaw_code.zip", code_zip, headers): - return self._result(False, "Code upload failed", start, session_id) + err = await self._upload_bytes(http, endpoint, session_id, "polyclaw_code.zip", code_zip, headers) + if err: + return self._result(False, f"Code upload failed: {err}", start, session_id) - setup = self._build_bootstrap_script("echo 'Session bootstrapped OK'", has_data=data_zip is not None) - if not await self._upload_bytes(http, endpoint, session_id, "bootstrap.sh", setup.encode(), headers): - return self._result(False, "Bootstrap upload failed", start, session_id) + setup = self._build_bootstrap_script("echo 'Session bootstrapped OK'", has_data=has_data) + err = await self._upload_bytes(http, endpoint, session_id, "bootstrap.sh", setup.encode(), headers) + if err: + return self._result(False, f"Bootstrap upload failed: {err}", start, session_id) exec_result = await self._execute_in_session(http, endpoint, session_id, headers, timeout=120) if not exec_result["success"]: @@ -413,19 +419,36 @@ async def _execute_code( text = await resp.text() return {"success": False, "error": f"HTTP {resp.status}: {text[:300]}"} result = await resp.json() - raw_stdout = result.get("properties", {}).get("stdout", "") - try: - output = json.loads(raw_stdout.strip()) - stdout, stderr, rc = output.get("stdout", ""), output.get("stderr", ""), output.get("rc", 0) - except (json.JSONDecodeError, AttributeError, TypeError): - stdout, stderr, rc = raw_stdout, result.get("properties", {}).get("stderr", ""), 1 - if rc != 0: - return {"success": False, "stdout": stdout, "stderr": stderr, "error": stderr or f"Exit code {rc}"} - return {"success": True, "stdout": stdout, "stderr": stderr} + props = result.get("properties", {}) + return self._parse_exec_result( + props.get("stdout", ""), + fallback_stderr=props.get("stderr", ""), + ) except Exception as exc: logger.error("Session exec exception: %s", exc, exc_info=True) return {"success": False, "error": str(exc)} + @staticmethod + def _parse_exec_result( + raw_stdout: str, fallback_stderr: str = "", + ) -> dict[str, Any]: + """Parse JSON-wrapped subprocess output into a result dict.""" + try: + output = json.loads(raw_stdout.strip()) + stdout = output.get("stdout", "") + stderr = output.get("stderr", "") + rc = output.get("rc", 0) + except (json.JSONDecodeError, AttributeError, TypeError): + stdout, stderr, rc = raw_stdout, fallback_stderr, 1 + if rc != 0: + return { + "success": False, + "stdout": stdout, + "stderr": stderr, + "error": stderr or f"Exit code {rc}", + } + return {"success": True, "stdout": stdout, "stderr": stderr} + async def destroy_session(self, session_id: str) -> None: if self._store.sync_data: try: @@ -457,164 +480,3 @@ def _timing(self, start: float, session_id: str) -> dict[str, Any]: def _result(self, success: bool, error: str, start: float, session_id: str) -> dict[str, Any]: return {"success": success, "error": error, **self._timing(start, session_id)} - - -class SandboxToolInterceptor: - def __init__(self, executor: SandboxExecutor) -> None: - self._executor = executor - self._session_id: str | None = None - self._session_ready: bool = False - self._provisioning: bool = False - self._last_activity: float = 0 - self._idle_task: asyncio.Task | None = None - self._pending_result: dict[str, Any] | None = None - - @property - def session_id(self) -> str | None: - return self._session_id - - async def _ensure_session(self) -> str: - self._last_activity = time.time() - if self._session_id and self._session_ready: - return self._session_id - - self._session_id = str(uuid.uuid4()) - self._session_ready = False - self._provisioning = True - - try: - result = await self._executor.provision_session(self._session_id) - if not result["success"]: - self._session_id = None - raise RuntimeError(f"Sandbox session provision failed: {result.get('error')}") - self._session_ready = True - finally: - self._provisioning = False - - self._start_idle_timer() - return self._session_id - - async def _teardown_session(self) -> None: - if not self._session_id: - return - sid = self._session_id - self._session_id = None - self._session_ready = False - if self._idle_task and not self._idle_task.done(): - self._idle_task.cancel() - self._idle_task = None - try: - await self._executor.destroy_session(sid) - except Exception as exc: - logger.warning("Session teardown error: %s", exc) - - def _start_idle_timer(self) -> None: - if self._idle_task and not self._idle_task.done(): - self._idle_task.cancel() - self._idle_task = asyncio.ensure_future(self._idle_reaper()) - - async def _idle_reaper(self) -> None: - try: - while True: - await asyncio.sleep(10) - if not self._session_id: - return - if time.time() - self._last_activity >= _SESSION_IDLE_TIMEOUT: - await self._teardown_session() - return - except asyncio.CancelledError: - pass - - def touch(self) -> None: - self._last_activity = time.time() - - async def on_pre_tool_use(self, input_data: dict, ctx: dict) -> dict | None: - tool_name = input_data.get("toolName", "") - if not self._executor.enabled: - return {"permissionDecision": "allow"} - if not _is_shell_tool(tool_name): - return {"permissionDecision": "allow"} - - tool_args = _parse_tool_args(input_data.get("toolArgs")) - command = _extract_command(tool_args) - if not command: - return {"permissionDecision": "allow"} - - try: - session_id = await self._ensure_session() - result = await self._executor.run_in_session(session_id, command, timeout=120) - self._last_activity = time.time() - except Exception as exc: - logger.error("Sandbox interceptor failed: %s", exc, exc_info=True) - result = {"success": False, "stdout": "", "stderr": str(exc)} - - self._pending_result = result - replay = _build_replay_command( - result.get("stdout", ""), result.get("stderr", ""), result.get("success", False) - ) - noop_args = dict(tool_args) - noop_args["command"] = replay - if "input" in noop_args: - noop_args["input"] = replay - return {"permissionDecision": "allow", "modifiedArgs": noop_args} - - async def on_post_tool_use(self, input_data: dict, ctx: dict) -> dict | None: - if self._pending_result is None: - return None - - result = self._pending_result - self._pending_result = None - - parts: list[str] = [] - if result.get("stdout"): - parts.append(result["stdout"]) - if result.get("stderr"): - parts.append(f"STDERR:\n{result['stderr']}") - output = "\n".join(parts) if parts else "(no output)" - if not result.get("success"): - output = f"Command failed in sandbox.\n{output}" - return {"modifiedResult": output} - - -def _parse_tool_args(raw: Any) -> dict: - if isinstance(raw, dict): - return raw - if isinstance(raw, str): - try: - parsed = json.loads(raw) - if isinstance(parsed, dict): - return parsed - except (json.JSONDecodeError, TypeError): - pass - return {} - - -def _extract_command(args: Any) -> str: - if isinstance(args, str): - try: - parsed = json.loads(args) - if isinstance(parsed, dict): - args = parsed - else: - return args - except (json.JSONDecodeError, TypeError): - return args - if isinstance(args, dict): - return args.get("command", "") or args.get("cmd", "") or args.get("input", "") or args.get("script", "") - return "" - - -def _is_shell_tool(name: str) -> bool: - lower = name.lower() - return any(p in lower for p in _SHELL_TOOL_PATTERNS) - - -def _build_replay_command(stdout: str, stderr: str, success: bool) -> str: - parts: list[str] = [] - if stdout: - parts.append(f"printf %s {shlex.quote(stdout)}") - if stderr: - parts.append(f"printf %s {shlex.quote(stderr)} >&2") - if not success: - parts.append("exit 1") - return " ; ".join(parts) if parts else "true" diff --git a/app/runtime/sandbox/helpers.py b/app/runtime/sandbox/helpers.py new file mode 100644 index 0000000..c606072 --- /dev/null +++ b/app/runtime/sandbox/helpers.py @@ -0,0 +1,53 @@ +"""Sandbox helper utilities for tool argument parsing and command replay.""" + +from __future__ import annotations + +import json +import shlex +from typing import Any + +_SHELL_TOOL_PATTERNS = ("terminal", "shell", "bash", "command") + + +def _parse_tool_args(raw: Any) -> dict: + if isinstance(raw, dict): + return raw + if isinstance(raw, str): + try: + parsed = json.loads(raw) + if isinstance(parsed, dict): + return parsed + except (json.JSONDecodeError, TypeError): + pass + return {} + + +def _extract_command(args: Any) -> str: + if isinstance(args, str): + try: + parsed = json.loads(args) + if isinstance(parsed, dict): + args = parsed + else: + return args + except (json.JSONDecodeError, TypeError): + return args + if isinstance(args, dict): + return args.get("command", "") or args.get("cmd", "") or args.get("input", "") or args.get("script", "") + return "" + + +def _is_shell_tool(name: str) -> bool: + lower = name.lower() + return any(p in lower for p in _SHELL_TOOL_PATTERNS) + + +def _build_replay_command(stdout: str, stderr: str, success: bool) -> str: + parts: list[str] = [] + if stdout: + parts.append(f"printf %s {shlex.quote(stdout)}") + if stderr: + parts.append(f"printf %s {shlex.quote(stderr)} >&2") + if not success: + parts.append("exit 1") + return " ; ".join(parts) if parts else "true" diff --git a/app/runtime/sandbox/interceptor.py b/app/runtime/sandbox/interceptor.py new file mode 100644 index 0000000..ca6b155 --- /dev/null +++ b/app/runtime/sandbox/interceptor.py @@ -0,0 +1,133 @@ +"""Sandbox tool interceptor -- intercepts shell tool calls for sandbox execution.""" + +from __future__ import annotations + +import asyncio +import logging +import time +import uuid +from typing import Any + +from .executor import SandboxExecutor +from .helpers import _build_replay_command, _extract_command, _is_shell_tool, _parse_tool_args + +logger = logging.getLogger(__name__) + +_SESSION_IDLE_TIMEOUT = 60 + + +class SandboxToolInterceptor: + def __init__(self, executor: SandboxExecutor) -> None: + self._executor = executor + self._session_id: str | None = None + self._session_ready: bool = False + self._provisioning: bool = False + self._last_activity: float = 0 + self._idle_task: asyncio.Task | None = None + self._pending_result: dict[str, Any] | None = None + + @property + def session_id(self) -> str | None: + return self._session_id + + async def _ensure_session(self) -> str: + self._last_activity = time.time() + if self._session_id and self._session_ready: + return self._session_id + + self._session_id = str(uuid.uuid4()) + self._session_ready = False + self._provisioning = True + + try: + result = await self._executor.provision_session(self._session_id) + if not result["success"]: + self._session_id = None + raise RuntimeError(f"Sandbox session provision failed: {result.get('error')}") + self._session_ready = True + finally: + self._provisioning = False + + self._start_idle_timer() + return self._session_id + + async def _teardown_session(self) -> None: + if not self._session_id: + return + sid = self._session_id + self._session_id = None + self._session_ready = False + if self._idle_task and not self._idle_task.done(): + self._idle_task.cancel() + self._idle_task = None + try: + await self._executor.destroy_session(sid) + except Exception as exc: + logger.warning("Session teardown error: %s", exc) + + def _start_idle_timer(self) -> None: + if self._idle_task and not self._idle_task.done(): + self._idle_task.cancel() + self._idle_task = asyncio.ensure_future(self._idle_reaper()) + + async def _idle_reaper(self) -> None: + try: + while True: + await asyncio.sleep(10) + if not self._session_id: + return + if time.time() - self._last_activity >= _SESSION_IDLE_TIMEOUT: + await self._teardown_session() + return + except asyncio.CancelledError: + pass + + def touch(self) -> None: + self._last_activity = time.time() + + async def on_pre_tool_use(self, input_data: dict, ctx: dict) -> dict | None: + tool_name = input_data.get("toolName", "") + if not self._executor.enabled: + return {"permissionDecision": "allow"} + if not _is_shell_tool(tool_name): + return {"permissionDecision": "allow"} + + tool_args = _parse_tool_args(input_data.get("toolArgs")) + command = _extract_command(tool_args) + if not command: + return {"permissionDecision": "allow"} + + try: + session_id = await self._ensure_session() + result = await self._executor.run_in_session(session_id, command, timeout=120) + self._last_activity = time.time() + except Exception as exc: + logger.error("Sandbox interceptor failed: %s", exc, exc_info=True) + result = {"success": False, "stdout": "", "stderr": str(exc)} + + self._pending_result = result + replay = _build_replay_command( + result.get("stdout", ""), result.get("stderr", ""), result.get("success", False) + ) + noop_args = dict(tool_args) + noop_args["command"] = replay + if "input" in noop_args: + noop_args["input"] = replay + return {"permissionDecision": "allow", "modifiedArgs": noop_args} + + async def on_post_tool_use(self, input_data: dict, ctx: dict) -> dict | None: + if self._pending_result is None: + return None + + result = self._pending_result + self._pending_result = None + + parts: list[str] = [] + if result.get("stdout"): + parts.append(result["stdout"]) + if result.get("stderr"): + parts.append(f"STDERR:\n{result['stderr']}") + output = "\n".join(parts) if parts else "(no output)" + if not result.get("success"): + output = f"Command failed in sandbox.\n{output}" + return {"modifiedResult": output} diff --git a/app/runtime/scheduler/__init__.py b/app/runtime/scheduler/__init__.py new file mode 100644 index 0000000..45c6f33 --- /dev/null +++ b/app/runtime/scheduler/__init__.py @@ -0,0 +1,25 @@ +"""Scheduler -- persistent task scheduling that spawns Copilot SDK sessions.""" + +from .engine import ( + MIN_INTERVAL_SECONDS, + SCHEDULED_MODEL, + ScheduledTask, + Scheduler, + _cron_matches, + _validate_cron, + get_scheduler, + scheduler_loop, + set_scheduler, +) + +__all__ = [ + "MIN_INTERVAL_SECONDS", + "SCHEDULED_MODEL", + "ScheduledTask", + "Scheduler", + "_cron_matches", + "_validate_cron", + "get_scheduler", + "scheduler_loop", + "set_scheduler", +] diff --git a/app/runtime/scheduler.py b/app/runtime/scheduler/engine.py similarity index 93% rename from app/runtime/scheduler.py rename to app/runtime/scheduler/engine.py index 72af951..897f65a 100644 --- a/app/runtime/scheduler.py +++ b/app/runtime/scheduler/engine.py @@ -1,4 +1,4 @@ -"""Scheduler -- persistent task scheduling that spawns Copilot SDK sessions.""" +"""Core scheduler engine -- persistent task scheduling that spawns Copilot SDK sessions.""" from __future__ import annotations @@ -14,16 +14,16 @@ from croniter import croniter -from .agent import one_shot as one_shot_mod -from .config.settings import cfg -from .util.singletons import register_singleton +from ..agent import one_shot as one_shot_mod +from ..config.settings import cfg +from ..util.singletons import register_singleton logger = logging.getLogger(__name__) SCHEDULED_MODEL = "gpt-4.1" MIN_INTERVAL_SECONDS = 3600 -_TEMPLATES_DIR = Path(__file__).resolve().parent / "templates" +_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" @dataclass @@ -214,7 +214,8 @@ def check_due(self) -> list[ScheduledTask]: gap = (now - last_dt).total_seconds() if gap < MIN_INTERVAL_SECONDS: logger.debug( - "[scheduler] task %s (%s) -- cron matches but too soon (%.0fs < %ds)", + "[scheduler] task %s (%s) -- cron matches but too soon" + " (%.0fs < %ds)", task.id, task.description, gap, MIN_INTERVAL_SECONDS, ) continue @@ -238,7 +239,7 @@ def check_due(self) -> list[ScheduledTask]: return due async def run_due_tasks(self) -> None: - from .state.profile import log_interaction + from ..state.profile import log_interaction for task in self.check_due(): logger.info( @@ -260,7 +261,7 @@ async def run_due_tasks(self) -> None: self._active_interceptor = None async def _spawn_session(self, task: ScheduledTask) -> str | None: - from .agent.tools import get_all_tools + from ..agent.tools import get_all_tools template = (_TEMPLATES_DIR / "scheduler_prompt.md").read_text() system_message = template.format( @@ -287,17 +288,16 @@ def _make_background_hook(self, model: str) -> Callable[..., Any]: PITL works if a ``PhoneVerifier`` is configured on the shared interceptor. AITL and Prompt Shields are also forwarded. """ - from .agent.hitl import HitlInterceptor - from .state.guardrails_config import get_guardrails_config + from ..agent.hitl import HitlInterceptor + from ..state.guardrails.config import get_guardrails_config store = get_guardrails_config() interceptor = HitlInterceptor(store) - interceptor.set_execution_context("scheduler") - interceptor.set_model(model) - - # Bind notification channel so HITL can interact with the user. - if self._notify: - interceptor.set_bot_reply_fn(self._notify) + interceptor.bind_turn( + execution_context="scheduler", + model=model, + bot_reply_fn=self._notify if self._notify else None, + ) # Forward AITL / Prompt Shield / phone from the shared interceptor. if self._hitl_interceptor: @@ -324,7 +324,10 @@ async def _send_notification(self, task: ScheduledTask, result: str | None) -> N await self._notify(msg) logger.info("[scheduler] notification sent for task %s", task.id) except Exception as exc: - logger.error("[scheduler] notification send failed for task %s: %s", task.id, exc, exc_info=True) + logger.error( + "[scheduler] notification send failed for task %s: %s", + task.id, exc, exc_info=True, + ) _scheduler: Scheduler | None = None diff --git a/app/runtime/server/__init__.py b/app/runtime/server/__init__.py index 697117e..707cfcf 100644 --- a/app/runtime/server/__init__.py +++ b/app/runtime/server/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations -from .app import AppFactory, create_adapter, create_app, main +from .app import AppFactory, create_app, main +from .wiring import create_adapter __all__ = ["AppFactory", "create_adapter", "create_app", "main"] diff --git a/app/runtime/server/app.py b/app/runtime/server/app.py index 6567742..28e52e2 100644 --- a/app/runtime/server/app.py +++ b/app/runtime/server/app.py @@ -3,283 +3,122 @@ from __future__ import annotations import asyncio -import hmac import logging -import mimetypes import os import secrets -import time from collections.abc import Awaitable, Callable -from pathlib import Path +from typing import TYPE_CHECKING, Any from aiohttp import web -from aiohttp.abc import AbstractAccessLogger from .. import __version__ from ..config.settings import ServerMode, cfg -from ..media import EXTENSION_TO_MIME -logger = logging.getLogger(__name__) - -_FRONTEND_DIR = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist" -_QUIET_PATHS = frozenset({"/api/setup/status", "/health"}) - - -class QuietAccessLogger(AbstractAccessLogger): - """Demotes polling-endpoint and noisy log entries to DEBUG.""" - - def log(self, request: web.BaseRequest, response: web.StreamResponse, time: float) -> None: - status = response.status - if request.path in _QUIET_PATHS or status == 401 or status in (502, 503): - level = logging.DEBUG - else: - level = logging.INFO - self.logger.log( - level, - "%s %s %s %s %.3fs", - request.remote, - request.method, - request.path, - status, - time, - ) - - -def create_adapter() -> object: - from botbuilder.core import BotFrameworkAdapter, BotFrameworkAdapterSettings, TurnContext - from botbuilder.schema import Activity, ActivityTypes - - settings = BotFrameworkAdapterSettings( - app_id=cfg.bot_app_id or None, - app_password=cfg.bot_app_password or None, - channel_auth_tenant=cfg.bot_app_tenant_id or None, - ) - adapter = BotFrameworkAdapter(settings) - - async def on_error(context: TurnContext, error: Exception) -> None: - logger.error("Bot turn error: %s", error, exc_info=True) - try: - activity = Activity(type=ActivityTypes.message, text="An error occurred.") - if (context.activity.channel_id or "").lower() == "telegram": - activity.text_format = "plain" - await context.send_activity(activity) - except Exception: - pass - - adapter.on_turn_error = on_error - return adapter - - -_PUBLIC_PREFIXES = ("/health", "/api/messages", "/acs", "/realtime-acs", "/api/voice/acs-callback", "/api/voice/media-streaming") -_PUBLIC_EXACT = ("/api/auth/check",) - -_TUNNEL_ALLOWED_PREFIXES = ( - "/health", - "/api/messages", - "/acs", - "/realtime-acs", - "/api/voice/acs-callback", - "/api/voice/media-streaming", +if TYPE_CHECKING: + from ..agent.agent import Agent + from ..messaging.bot import Bot + from ..messaging.proactive import ConversationReferenceStore + from ..sandbox import SandboxExecutor + from ..scheduler import Scheduler + from ..services.cloud.azure import AzureCLI + from ..services.cloud.github import GitHubAuth + from ..services.deployment.aca_deployer import AcaDeployer + from ..services.deployment.deployer import BotDeployer + from ..services.deployment.provisioner import Provisioner + from ..services.tunnel import CloudflareTunnel + from ..state.deploy_state import DeployStateStore + from ..state.foundry_iq_config import FoundryIQConfigStore + from ..state.guardrails import GuardrailsConfigStore + from ..state.infra_config import InfraConfigStore + from ..state.mcp_config import McpConfigStore + from ..state.monitoring_config import MonitoringConfigStore + from ..state.proactive import ProactiveStore + from ..state.sandbox_config import SandboxConfigStore + from ..state.session_store import SessionStore + from .bot_endpoint import BotEndpoint +from . import lifecycle +from .app_routes import register_admin_routes, register_runtime_routes +from .app_static import ( + FRONTEND_DIR, + make_file_handler, + serve_index, + serve_media, + serve_spa_or_404, ) - -_LOCKDOWN_ALLOWED_PREFIXES = ( - "/health", - "/api/messages", - "/acs", - "/realtime-acs", - "/api/voice/acs-callback", - "/api/voice/media-streaming", - "/api/setup/lockdown", +from .middleware import ( + auth_middleware, + lockdown_middleware, + tunnel_restriction_middleware, ) +from .wiring import create_adapter, create_voice_handler, init_core, init_services -_CF_HEADERS = ("cf-connecting-ip", "cf-ray", "cf-ipcountry") - - -@web.middleware -async def lockdown_middleware(request: web.Request, handler): # type: ignore[type-arg] - if not cfg.lockdown_mode: - return await handler(request) - if any(request.path.startswith(p) for p in _LOCKDOWN_ALLOWED_PREFIXES): - return await handler(request) - return web.json_response( - { - "status": "locked", - "message": ( - "Lock Down Mode is active. The admin panel is disabled. " - "Use /lockdown off via the bot to restore access." - ), - }, - status=403, - ) - - -@web.middleware -async def tunnel_restriction_middleware(request: web.Request, handler): # type: ignore[type-arg] - if not cfg.tunnel_restricted: - return await handler(request) - is_tunnel = any(request.headers.get(h) for h in _CF_HEADERS) - if not is_tunnel: - return await handler(request) - if any(request.path.startswith(p) for p in _TUNNEL_ALLOWED_PREFIXES): - return await handler(request) - return web.json_response({"status": "forbidden"}, status=403) - - -@web.middleware -async def auth_middleware(request: web.Request, handler): # type: ignore[type-arg] - secret = cfg.admin_secret - if not secret: - return await handler(request) - - path = request.path - - # Only protect /api/* endpoints (except public ones); frontend assets are public - if not path.startswith("/api/"): - return await handler(request) - - if path in _PUBLIC_EXACT or any(path.startswith(p) for p in _PUBLIC_PREFIXES): - return await handler(request) - - auth = request.headers.get("Authorization", "") - expected = f"Bearer {secret}" - if hmac.compare_digest(auth, expected): - return await handler(request) - - token_param = request.query.get("token", "") - if token_param and hmac.compare_digest(token_param, secret): - return await handler(request) - - secret_param = request.query.get("secret", "") - if secret_param and hmac.compare_digest(secret_param, secret): - return await handler(request) - - return web.json_response( - {"status": "unauthorized", "message": "Invalid or missing admin secret"}, - status=401, - ) - - -def _append_token(url: str, token: str) -> str: - sep = "&" if "?" in url else "?" - return f"{url}{sep}token={token}" +logger = logging.getLogger(__name__) async def create_app() -> web.Application: + """Public entry point -- build and return the ``aiohttp`` application.""" factory = AppFactory() return await factory.build() -def _create_voice_handler(agent: object, tunnel: object | None = None) -> object | None: - cfg.reload() - if not (cfg.acs_connection_string and cfg.acs_source_number and cfg.azure_openai_endpoint): - logger.info("Voice call not configured (ACS/AOAI settings missing)") - return None - - from azure.core.credentials import AzureKeyCredential as _AKC - - from ..realtime import AcsCaller, RealtimeMiddleTier, RealtimeRoutes - - def _resolve_acs_urls() -> tuple[str, str]: - token = cfg.acs_callback_token - cb_path = cfg.acs_callback_path - ws_path = cfg.acs_media_streaming_websocket_path - - logger.debug("_resolve_acs_urls: cb_path=%r, ws_path=%r, token=%s", cb_path, ws_path, "set" if token else "empty") - - # If both paths are already absolute URLs, use them directly - cb_is_absolute = cb_path.startswith("https://") - ws_is_absolute = ws_path.startswith("wss://") - if cb_is_absolute and ws_is_absolute: - resolved = _append_token(cb_path, token), _append_token(ws_path, token) - logger.info("ACS URLs (absolute): callback=%s, ws=%s", resolved[0], resolved[1]) - return resolved - - # Otherwise, resolve relative paths against the tunnel URL - tunnel_url = (getattr(tunnel, 'url', None) or "").rstrip("/") - if tunnel_url: - cb = cb_path if cb_is_absolute else f"{tunnel_url}{cb_path or '/api/voice/acs-callback'}" - ws = ws_path if ws_is_absolute else ( - tunnel_url.replace("https://", "wss://").replace("http://", "ws://") - + (ws_path or "/api/voice/media-streaming") - ) - resolved = _append_token(cb, token), _append_token(ws, token) - logger.info("ACS URLs (tunnel): callback=%s, ws=%s", resolved[0], resolved[1]) - return resolved - logger.warning("ACS URLs fallback to localhost -- calls will fail") - return ( - cb_path or f"http://localhost:{cfg.admin_port}/api/voice/acs-callback", - ws_path or f"ws://localhost:{cfg.admin_port}/api/voice/media-streaming", - ) - - caller = AcsCaller( - source_number=cfg.acs_source_number, - acs_connection_string=cfg.acs_connection_string, - resolve_urls=_resolve_acs_urls, - resolve_source_number=lambda: cfg.acs_source_number, - ) - - realtime_credential: _AKC | object - if cfg.azure_openai_api_key: - realtime_credential = _AKC(cfg.azure_openai_api_key) - else: - from azure.identity import DefaultAzureCredential as _DAC - - realtime_credential = _DAC() - - rt_middleware = RealtimeMiddleTier( - endpoint=cfg.azure_openai_endpoint, - deployment=cfg.azure_openai_realtime_deployment, - credential=realtime_credential, - agent=agent, - ) - handler = RealtimeRoutes( - caller, - rt_middleware, - callback_token=cfg.acs_callback_token, - acs_resource_id=cfg.acs_resource_id, - ) - logger.info("Voice call (ACS + Realtime) enabled: source=%s", cfg.acs_source_number) - return handler - - -_SCHEDULE_INTERVALS = {"hourly": 3600, "daily": 86400} - - class AppFactory: + """Assembles the aiohttp application with routes, middleware, and lifecycle hooks. + + All dependency references are declared in ``__init__`` so the full shape + of the object is visible in one place. + """ + + def __init__(self) -> None: + self._mode: ServerMode = cfg.server_mode + + # Core components (populated by _init_core) + self._agent: Agent | None = None + self._adapter: Any = None # BotFrameworkAdapter (external) + self._conv_store: ConversationReferenceStore | None = None + self._session_store: SessionStore | None = None + self._bot: Bot | None = None + self._bot_ep: BotEndpoint | None = None + + # State stores (populated by _init_services) + self._deploy_store: DeployStateStore | None = None + self._infra_store: InfraConfigStore | None = None + self._mcp_store: McpConfigStore | None = None + self._sandbox_store: SandboxConfigStore | None = None + self._foundry_iq_store: FoundryIQConfigStore | None = None + self._guardrails_store: GuardrailsConfigStore | None = None + self._monitoring_store: MonitoringConfigStore | None = None + + # External services (populated by _init_services) + self._tunnel: CloudflareTunnel | None = None + self._az: AzureCLI | None = None + self._gh: GitHubAuth | None = None + self._deployer: BotDeployer | None = None + self._provisioner: Provisioner | None = None + self._aca_deployer: AcaDeployer | None = None + + # Runtime-only services (populated by _init_services) + self._scheduler: Scheduler | None = None + self._proactive_store: ProactiveStore | None = None + self._sandbox_executor: SandboxExecutor | None = None + + # Voice handler (populated by _init_voice) + self._voice_routes: Any = None + + # -- Public API -------------------------------------------------------- async def build(self) -> web.Application: + """Wire everything together and return the application.""" self._mode = cfg.server_mode cfg.ensure_dirs() self._ensure_admin_secret() await self._init_core() self._init_services() - - if self._bot and self._agent and self._agent.hitl_interceptor: - self._bot._hitl = self._agent.hitl_interceptor - self._bot._processor._hitl = self._agent.hitl_interceptor - - if self._scheduler and self._agent and self._agent.hitl_interceptor: - self._scheduler.set_hitl_interceptor(self._agent.hitl_interceptor) - if self._bot and self._scheduler: - self._bot._scheduler = self._scheduler - + self._cross_wire() self._init_voice() middlewares = [lockdown_middleware, tunnel_restriction_middleware, auth_middleware] - - # Admin-only mode: proxy unmatched /api/* requests to runtime - proxy_mw = None - if self._is_admin and not self._is_runtime: - from .runtime_proxy import create_runtime_proxy_middleware - - if os.getenv("POLYCLAW_USE_MI"): - aca_fqdn = cfg.env.read("ACA_RUNTIME_FQDN") - if aca_fqdn: - aca_url = f"https://{aca_fqdn}" - os.environ["RUNTIME_URL"] = aca_url - logger.info("[startup] Restored RUNTIME_URL=%s from ACA deployment", aca_url) - - proxy_mw = create_runtime_proxy_middleware() + proxy_mw = self._maybe_create_proxy() + if proxy_mw is not None: middlewares.append(proxy_mw) app = web.Application(middlewares=middlewares) @@ -294,6 +133,8 @@ async def build(self) -> web.Application: return app + # -- Properties -------------------------------------------------------- + @property def _is_admin(self) -> bool: return self._mode in (ServerMode.admin, ServerMode.combined) @@ -302,6 +143,8 @@ def _is_admin(self) -> bool: def _is_runtime(self) -> bool: return self._mode in (ServerMode.runtime, ServerMode.combined) + # -- Initialisation (delegates to wiring module) ----------------------- + @staticmethod def _ensure_admin_secret() -> None: if cfg.admin_secret: @@ -316,120 +159,69 @@ def _ensure_admin_secret() -> None: logger.info("Generated ADMIN_SECRET (persisted to .env)") async def _init_core(self) -> None: - self._agent = None - self._adapter = None - self._conv_store = None - self._session_store = None - self._bot = None - self._bot_ep = None - - if self._is_runtime: - from ..agent.agent import Agent - from ..messaging.bot import Bot - from ..messaging.proactive import ConversationReferenceStore - from ..state.session_store import SessionStore - from .bot_endpoint import BotEndpoint - - logger.info("[init_core] creating Agent ...") - self._agent = Agent() - logger.info("[init_core] starting Agent (Copilot CLI) ...") - await self._agent.start() - logger.info("[init_core] Agent started successfully") - - self._adapter = create_adapter() - self._conv_store = ConversationReferenceStore() - self._session_store = SessionStore() - - hitl = self._agent.hitl_interceptor if self._agent else None - self._bot = Bot(self._agent, self._conv_store, hitl=hitl) - self._bot.session_store = self._session_store - self._bot.adapter = self._adapter - self._bot_ep = BotEndpoint(self._adapter, self._bot) - logger.info("[init_core] core initialization complete") - - if self._is_admin and not self._is_runtime: - from ..state.session_store import SessionStore - - self._session_store = SessionStore() - logger.info("[init_core] admin-only initialization complete") + core = await init_core(self._mode) + self._agent = core["agent"] + self._adapter = core["adapter"] + self._conv_store = core["conv_store"] + self._session_store = core["session_store"] + self._bot = core["bot"] + self._bot_ep = core["bot_ep"] def _init_services(self) -> None: - from ..state.deploy_state import DeployStateStore - from ..state.foundry_iq_config import FoundryIQConfigStore - from ..state.guardrails_config import GuardrailsConfigStore - from ..state.infra_config import InfraConfigStore - from ..state.mcp_config import McpConfigStore - from ..state.monitoring_config import MonitoringConfigStore - from ..state.sandbox_config import SandboxConfigStore - - self._tunnel = None - if self._is_runtime: - from ..services.tunnel import CloudflareTunnel - - self._tunnel = CloudflareTunnel() - self._deploy_store = DeployStateStore() - self._infra_store = InfraConfigStore() - self._mcp_store = McpConfigStore() - self._sandbox_store = SandboxConfigStore() - self._foundry_iq_store = FoundryIQConfigStore() - self._guardrails_store = GuardrailsConfigStore() - self._monitoring_store = MonitoringConfigStore() - - # Admin-side services: Azure CLI, GitHub auth, deployer, provisioner - self._az = None - self._gh = None - self._deployer = None - self._provisioner = None - self._aca_deployer = None - if self._is_admin: - from ..services.aca_deployer import AcaDeployer - from ..services.azure import AzureCLI - from ..services.deployer import BotDeployer - from ..services.github import GitHubAuth - from ..services.provisioner import Provisioner - - self._az = AzureCLI() - self._gh = GitHubAuth() - self._deployer = BotDeployer(self._az, self._deploy_store) - self._provisioner = Provisioner( - self._az, self._deployer, - self._infra_store, self._deploy_store, - tunnel=self._tunnel, - ) - self._aca_deployer = AcaDeployer(self._az, self._deploy_store) - elif self._is_runtime: - from ..services.azure import AzureCLI - from ..services.deployer import BotDeployer - from ..services.provisioner import Provisioner - - self._az = AzureCLI() - self._deployer = BotDeployer(self._az, self._deploy_store) - self._provisioner = Provisioner( - self._az, self._deployer, - self._infra_store, self._deploy_store, - tunnel=self._tunnel, - ) - - # Runtime-side services: scheduler, sandbox, proactive - self._scheduler = None - self._proactive_store = None - self._sandbox_executor = None - if self._is_runtime: - from ..sandbox import SandboxExecutor - from ..scheduler import get_scheduler - from ..state.proactive import get_proactive_store - - self._scheduler = get_scheduler() - self._proactive_store = get_proactive_store() - self._sandbox_executor = SandboxExecutor(self._sandbox_store) - if self._agent: + svc = init_services(self._mode) + self._tunnel = svc["tunnel"] + self._deploy_store = svc["deploy_store"] + self._infra_store = svc["infra_store"] + self._mcp_store = svc["mcp_store"] + self._sandbox_store = svc["sandbox_store"] + self._foundry_iq_store = svc["foundry_iq_store"] + self._guardrails_store = svc["guardrails_store"] + self._monitoring_store = svc["monitoring_store"] + self._az = svc["az"] + self._gh = svc["gh"] + self._deployer = svc["deployer"] + self._provisioner = svc["provisioner"] + self._aca_deployer = svc["aca_deployer"] + self._scheduler = svc["scheduler"] + self._proactive_store = svc["proactive_store"] + self._sandbox_executor = svc["sandbox_executor"] + + # Wire sandbox and guardrails into agent + if self._is_runtime and self._agent: + if self._sandbox_executor: self._agent.set_sandbox(self._sandbox_executor) - self._agent.set_guardrails(self._guardrails_store) + self._agent.set_guardrails(self._guardrails_store) + + def _cross_wire(self) -> None: + """Wire cross-cutting references that span core and services.""" + if self._bot and self._agent and self._agent.hitl_interceptor: + self._bot._hitl = self._agent.hitl_interceptor + self._bot._processor._hitl = self._agent.hitl_interceptor + + if self._scheduler and self._agent and self._agent.hitl_interceptor: + self._scheduler.set_hitl_interceptor(self._agent.hitl_interceptor) + if self._bot and self._scheduler: + self._bot._scheduler = self._scheduler def _init_voice(self) -> None: self._voice_routes = None if self._is_runtime: - self._voice_routes = _create_voice_handler(self._agent, self._tunnel) + self._voice_routes = create_voice_handler(self._agent, self._tunnel) + + def _maybe_create_proxy(self) -> object | None: + """Create the runtime proxy middleware for admin-only mode.""" + if not (self._is_admin and not self._is_runtime): + return None + from .runtime_proxy import create_runtime_proxy_middleware + + if os.getenv("POLYCLAW_USE_MI"): + aca_fqdn = cfg.env.read("ACA_RUNTIME_FQDN") + if aca_fqdn: + aca_url = f"https://{aca_fqdn}" + os.environ["RUNTIME_URL"] = aca_url + logger.info("[startup] Restored RUNTIME_URL=%s from ACA deployment", aca_url) + + return create_runtime_proxy_middleware() def _rebuild_adapter(self) -> object: cfg.reload() @@ -463,7 +255,7 @@ async def auth_check(req: web.Request) -> web.Response: self._register_runtime_routes(app) # Shared routes (both modes) - router.add_get("/api/media/{filename:.+}", _serve_media) + router.add_get("/api/media/{filename:.+}", serve_media) router.add_get("/health", self._health_handler()) # Frontend SPA -- served by admin in split mode, or by combined @@ -472,124 +264,32 @@ async def auth_check(req: web.Request) -> web.Response: def _register_admin_routes(self, router: web.UrlDispatcher) -> None: """Routes available only in ``admin`` or ``combined`` mode.""" - from .setup import SetupRoutes - from .setup_voice import VoiceSetupRoutes - from .workspace import WorkspaceHandler - from .routes.content_safety_routes import ContentSafetyRoutes - from .routes.env_routes import EnvironmentRoutes - from .routes.foundry_iq_routes import FoundryIQRoutes - from .routes.network_routes import NetworkRoutes - from .routes.monitoring_routes import MonitoringRoutes - from .routes.sandbox_routes import SandboxRoutes - - SetupRoutes( - self._az, self._gh, self._tunnel, self._deployer, - self._rebuild_adapter, self._infra_store, - self._provisioner, self._deploy_store, - self._aca_deployer, - ).register(router) - - VoiceSetupRoutes(self._az, self._infra_store).register(router) - WorkspaceHandler().register(router) - EnvironmentRoutes(self._deploy_store, self._az).register(router) - SandboxRoutes( - self._sandbox_store, self._sandbox_executor, self._az, self._deploy_store, - ).register(router) - FoundryIQRoutes(self._foundry_iq_store, self._az, self._deploy_store).register(router) - NetworkRoutes(self._tunnel, self._az, self._sandbox_store, self._foundry_iq_store).register(router) - MonitoringRoutes( - self._monitoring_store, self._az, self._deploy_store, - ).register(router) - ContentSafetyRoutes(self._az, self._guardrails_store).register(router) - - from .routes.identity_routes import IdentityRoutes - IdentityRoutes(self._az, self._guardrails_store).register(router) - - if self._az: - from .routes.security_preflight_routes import SecurityPreflightRoutes - from ..services.security_preflight import SecurityPreflightChecker - - SecurityPreflightRoutes(SecurityPreflightChecker(self._az)).register(router) + register_admin_routes( + router, + az=self._az, gh=self._gh, tunnel=self._tunnel, + deployer=self._deployer, rebuild_adapter=self._rebuild_adapter, + infra_store=self._infra_store, provisioner=self._provisioner, + deploy_store=self._deploy_store, aca_deployer=self._aca_deployer, + sandbox_store=self._sandbox_store, + sandbox_executor=self._sandbox_executor, + foundry_iq_store=self._foundry_iq_store, + monitoring_store=self._monitoring_store, + guardrails_store=self._guardrails_store, + ) def _register_runtime_routes(self, app: web.Application) -> None: """Routes available only in ``runtime`` or ``combined`` mode.""" - from ..agent.aitl import AitlReviewer - from ..agent.phone_verify import PhoneVerifier - from ..registries.plugins import get_plugin_registry - from ..registries.skills import get_registry as get_skill_registry - from ..services.prompt_shield import PromptShieldService - from ..state.plugin_config import PluginConfigStore - from .chat import ChatHandler - from .routes.guardrails_routes import GuardrailsRoutes - from .routes.mcp_routes import McpRoutes - from .routes.plugin_routes import PluginRoutes - from .routes.proactive_routes import ProactiveRoutes - from .routes.profile_routes import ProfileRoutes - from .routes.scheduler_routes import SchedulerRoutes - from .routes.session_routes import SessionRoutes - from .routes.skill_routes import SkillRoutes - from .routes.tool_activity_routes import ToolActivityRoutes - - router = app.router - - router.add_post("/api/internal/reload", self._handle_reload) - - from .routes.network_routes import NetworkRoutes as _NR - _nr_instance = _NR(self._tunnel) - router.add_get("/api/network/endpoints", _nr_instance._endpoints) - - hitl = self._agent.hitl_interceptor if self._agent else None - - # Wire phone verifier into HITL interceptor - if hitl: - phone_verifier = PhoneVerifier(app) - hitl.set_phone_verifier(phone_verifier) - app["_phone_verifier"] = phone_verifier - - # Wire AITL reviewer - gcfg = self._guardrails_store.config - aitl_reviewer = AitlReviewer( - model=gcfg.aitl_model, - spotlighting=gcfg.aitl_spotlighting, - ) - hitl.set_aitl_reviewer(aitl_reviewer) - - prompt_shield = PromptShieldService( - endpoint=gcfg.content_safety_endpoint, - mode=gcfg.filter_mode, - ) - hitl.set_prompt_shield(prompt_shield) - - ChatHandler( - self._agent, - session_store=self._session_store, - sandbox_interceptor=self._sandbox_executor, - hitl_interceptor=hitl, - ).register(router) - - self._bot_ep.register(router) - self._register_voice_dynamic(app) - - SchedulerRoutes(self._scheduler).register(router) - SessionRoutes(self._session_store).register(router) - SkillRoutes(get_skill_registry()).register(router) - McpRoutes(self._mcp_store).register(router) - PluginRoutes(get_plugin_registry(), PluginConfigStore()).register(router) - ProfileRoutes().register(router) - GuardrailsRoutes( - self._guardrails_store, self._mcp_store, - skills_registry=get_skill_registry(), - ).register(router) - - from ..state.tool_activity_store import get_tool_activity_store - ToolActivityRoutes(get_tool_activity_store(), self._session_store).register(router) - - ProactiveRoutes( - self._proactive_store, - adapter=self._adapter, - conv_store=self._conv_store, - app_id=cfg.bot_app_id, - ).register(router) + register_runtime_routes( + app, + agent=self._agent, session_store=self._session_store, + sandbox_executor=self._sandbox_executor, + mcp_store=self._mcp_store, guardrails_store=self._guardrails_store, + scheduler=self._scheduler, proactive_store=self._proactive_store, + adapter=self._adapter, conv_store=self._conv_store, + bot_ep=self._bot_ep, tunnel=self._tunnel, + voice_routes=self._voice_routes, + handle_reload=self._handle_reload, + ) def _health_handler(self) -> Callable: """Return a health handler that includes mode and tunnel info.""" @@ -604,75 +304,19 @@ async def handler(_req: web.Request) -> web.Response: return handler def _register_frontend(self, router: web.UrlDispatcher) -> None: - fe = _FRONTEND_DIR + fe = FRONTEND_DIR if not fe.exists(): return - router.add_get("/", _serve_index) + router.add_get("/", serve_index) if (fe / "assets").is_dir(): router.add_static("/assets/", path=str(fe / "assets"), name="fe_assets") for fname in ("favicon.ico", "logo.png", "headertext.png"): fpath = fe / fname if fpath.exists(): - router.add_get(f"/{fname}", _make_file_handler(fpath)) - router.add_get("/{tail:[^/].*}", _serve_spa_or_404) - - def _register_voice_dynamic(self, app: web.Application) -> None: - app["_voice_handler"] = self._voice_routes - agent = self._agent - - def reinit_voice() -> None: - handler = _create_voice_handler(agent, self._tunnel) - app["_voice_handler"] = handler - app["voice_configured"] = handler is not None - - app["_reinit_voice"] = reinit_voice - - def _not_configured() -> web.Response: - return web.json_response( - { - "status": "error", - "message": ( - "Voice calling is not configured. Deploy ACS + " - "Azure OpenAI resources in the Voice Call section first." - ), - }, - status=400, - ) - - async def voice_call(req: web.Request) -> web.Response: - h = req.app["_voice_handler"] - return _not_configured() if h is None else await h._api_call(req) - - async def voice_status(req: web.Request) -> web.Response: - h = req.app["_voice_handler"] - return _not_configured() if h is None else await h._api_status(req) - - async def acs_callback(req: web.Request) -> web.Response: - h = req.app["_voice_handler"] - logger.info("ACS callback hit: method=%s path=%s handler=%s", req.method, req.path, "configured" if h else "NONE") - return _not_configured() if h is None else await h._acs_callback(req) + router.add_get(f"/{fname}", make_file_handler(fpath)) + router.add_get("/{tail:[^/].*}", serve_spa_or_404) - async def acs_incoming(req: web.Request) -> web.Response: - h = req.app["_voice_handler"] - logger.info("ACS incoming hit: method=%s path=%s handler=%s", req.method, req.path, "configured" if h else "NONE") - return _not_configured() if h is None else await h._acs_incoming(req) - - async def ws_handler_acs(req: web.Request) -> web.WebSocketResponse: - h = req.app["_voice_handler"] - logger.info("ACS media-streaming WS hit: method=%s path=%s handler=%s", req.method, req.path, "configured" if h else "NONE") - return _not_configured() if h is None else await h._ws_handler_acs(req) # type: ignore[return-value] - - router = app.router - router.add_post("/api/voice/call", voice_call) - router.add_get("/api/voice/status", voice_status) - # Legacy routes (kept for backwards compat) - router.add_post("/acs", acs_callback) - router.add_post("/acs/incoming", acs_incoming) - router.add_get("/realtime-acs", ws_handler_acs) - # Routes matching cfg.acs_callback_path / cfg.acs_media_streaming_websocket_path - router.add_post("/api/voice/acs-callback", acs_callback) - router.add_post("/api/voice/acs-callback/incoming", acs_incoming) - router.add_get("/api/voice/media-streaming", ws_handler_acs) + # -- Lifecycle (delegates to lifecycle module) -------------------------- def _make_notify(self) -> Callable[[str], Awaitable[bool]]: from ..messaging.proactive import send_proactive_message @@ -700,214 +344,41 @@ async def notify(message: str) -> None: async def _on_startup(self, app: web.Application) -> None: if self._is_runtime: - await self._on_startup_runtime(app) - if self._is_admin: - await self._on_startup_admin(app) - - async def _on_startup_runtime(self, app: web.Application) -> None: - """Start background tasks and bot infrastructure for the runtime.""" - from ..proactive_loop import proactive_delivery_loop - from ..scheduler import scheduler_loop - from ..services.otel import configure_otel - - # Bootstrap OTel if monitoring is configured - mon = self._monitoring_store - if mon.is_configured: - configure_otel( - mon.connection_string, - sampling_ratio=mon.config.sampling_ratio, - enable_live_metrics=mon.config.enable_live_metrics, - ) - - self._rebuild_adapter() - - app["scheduler_task"] = asyncio.create_task(scheduler_loop()) - app["proactive_task"] = asyncio.create_task( - proactive_delivery_loop(self._make_notify(), session_store=self._session_store), - ) - app["foundry_iq_task"] = asyncio.create_task( - _foundry_iq_index_loop(self._foundry_iq_store), - ) - - logger.info( - "[startup.runtime] mode=%s lockdown=%s bot_configured=%s " - "telegram_configured=%s tunnel=%s provisioner=%s az=%s", - self._mode.value, cfg.lockdown_mode, - self._infra_store.bot_configured if self._infra_store else "", - self._infra_store.telegram_configured if self._infra_store else "", - self._tunnel is not None, - self._provisioner is not None, - self._az is not None, - ) - - if cfg.lockdown_mode: - logger.info("Lock Down Mode active -- skipping infrastructure provisioning") - return - - bot_endpoint = os.environ.get("BOT_ENDPOINT", "") - - if self._mode != ServerMode.combined: - github_token = cfg.github_token - if not github_token: - logger.warning( - "[startup.runtime] Setup incomplete -- missing GITHUB_TOKEN. " - "Complete the setup wizard in the admin container, " - "then recreate the agent container.", - ) - return - - needs_bot = ( - self._infra_store.bot_configured - and self._infra_store.telegram_configured - ) - - if self._mode == ServerMode.combined: - if self._infra_store.bot_configured and self._provisioner: - from ..util.async_helpers import run_sync - - logger.info("Startup: provisioning infrastructure from config ...") - steps = await run_sync(self._provisioner.provision) - self._rebuild_adapter() - for s in steps: - logger.info( - " provision: %s = %s (%s)", - s.get("step"), s.get("status"), s.get("detail", ""), - ) - if needs_bot and self._tunnel: - await self._start_tunnel_and_create_bot() - - elif bot_endpoint: - cfg.reload() - self._rebuild_adapter() - if needs_bot: - logger.info("Static bot endpoint: %s", bot_endpoint) - await self._recreate_bot(endpoint_override=bot_endpoint) - else: - logger.info("No messaging channels configured -- skipping bot service") - - else: - if needs_bot and self._tunnel: - from ..services.deployer import BotDeployer - - bot_app_id = BotDeployer._env("BOT_APP_ID") - if not bot_app_id: - logger.warning( - "Telegram configured but BOT_APP_ID missing -- " - "run Infrastructure Deploy in the admin wizard first" - ) - else: - await self._start_tunnel_and_create_bot() - else: - reasons = [] - if not self._infra_store.bot_configured: - reasons.append("bot not configured") - if not self._infra_store.telegram_configured: - reasons.append("no channels configured") - if not self._tunnel: - reasons.append("no tunnel") - logger.info( - "Skipping bot service: %s", - ", ".join(reasons) or "no reason", - ) - - async def _on_startup_admin(self, app: web.Application) -> None: - """Admin startup: reconcile stale deployments and RBAC.""" - if self._az: - from ..services.resource_tracker import ResourceTracker - from ..util.async_helpers import run_sync - - app["reconcile_task"] = asyncio.create_task(self._reconcile_deployments()) - app["cs_rbac_task"] = asyncio.create_task( - self._ensure_content_safety_rbac(), + await lifecycle.on_startup_runtime( + app, + mode=self._mode, + adapter=self._adapter, + bot=self._bot, + bot_ep=self._bot_ep, + conv_store=self._conv_store, + agent=self._agent, + tunnel=self._tunnel, + infra_store=self._infra_store, + provisioner=self._provisioner, + az=self._az, + monitoring_store=self._monitoring_store, + session_store=self._session_store, + foundry_iq_store=self._foundry_iq_store, + scheduler=self._scheduler, + rebuild_adapter=self._rebuild_adapter, + make_notify=self._make_notify, ) - - async def _ensure_content_safety_rbac(self) -> None: - from .routes.content_safety_routes import ContentSafetyRoutes - - try: - routes = ContentSafetyRoutes( + if self._is_admin: + await lifecycle.on_startup_admin( + app, az=self._az, + deploy_store=self._deploy_store, guardrails_store=self._guardrails_store, ) - steps = await routes.ensure_rbac() - for s in steps: - logger.info( - "[startup.cs_rbac] %s = %s (%s)", - s.get("step"), s.get("status"), s.get("detail", ""), - ) - except Exception: - logger.warning( - "[startup.cs_rbac] Content Safety RBAC check failed", - exc_info=True, - ) - - async def _recreate_bot(self, *, endpoint_override: str | None = None) -> None: - from ..util.async_helpers import run_sync - logger.info( - "[recreate_bot] provisioner=%s az=%s bot_configured=%s endpoint_override=%s", - self._provisioner is not None, - self._az is not None, - self._infra_store.bot_configured if self._infra_store else "?", - endpoint_override, + async def _on_cleanup(self, app: web.Application) -> None: + await lifecycle.on_cleanup( + app, + mode=self._mode, + infra_store=self._infra_store, + provisioner=self._provisioner, + agent=self._agent, ) - if not (self._provisioner and self._az and self._infra_store.bot_configured): - logger.warning( - "[recreate_bot] precondition failed -- provisioner=%s az=%s bot_configured=%s", - self._provisioner is not None, - self._az is not None, - self._infra_store.bot_configured if self._infra_store else "?", - ) - return - - tunnel_url = endpoint_override or getattr(self._tunnel, "url", None) - if not tunnel_url: - logger.warning("Bot recreate: no endpoint URL available -- skipping") - return - - endpoint = tunnel_url - logger.info("Bot recreate: endpoint %s", endpoint) - try: - steps = await run_sync(self._provisioner.recreate_endpoint, endpoint) - self._rebuild_adapter() - for s in steps: - logger.info( - " recreate: %s = %s (%s)", - s.get("step"), s.get("status"), s.get("detail", ""), - ) - except Exception as exc: - logger.warning("Bot recreate: error -- %s", exc, exc_info=True) - - async def _start_tunnel_and_create_bot(self) -> None: - from ..util.async_helpers import run_sync - - logger.info("Starting tunnel for bot service endpoint ...") - tunnel_url = self._tunnel.url - if not tunnel_url and not self._tunnel.is_active: - max_retries = 5 - for attempt in range(1, max_retries + 1): - result = await run_sync(self._tunnel.start, cfg.admin_port) - if result: - logger.info("Tunnel started at %s", result.value) - break - if attempt < max_retries: - logger.warning( - "Tunnel failed (attempt %d/%d): %s -- retrying in %ds ...", - attempt, max_retries, - result.message if result else "unknown", - 2 * attempt, - ) - await asyncio.sleep(2 * attempt) - else: - logger.error( - "Tunnel failed after %d attempts: %s", - max_retries, - result.message if result else "unknown", - ) - return - - self._rebuild_adapter() - await self._recreate_bot() async def _handle_reload(self, request: web.Request) -> web.Response: logger.info("[reload] triggered by admin -- re-reading configuration") @@ -915,20 +386,20 @@ async def _handle_reload(self, request: web.Request) -> web.Response: # 1. Re-read .env from shared volume cfg.reload() - # 2. Reload infra config (bot & channel settings from infra.json) + # 2. Reload infra config if self._infra_store: self._infra_store._load() - # 3. Reload agent auth (GITHUB_TOKEN may have changed) + # 3. Reload agent auth auth_result: dict = {} if self._agent: auth_result = await self._agent.reload_auth() logger.info("[reload] agent auth: %s", auth_result.get("status")) - # 4. Rebuild Bot Framework adapter (BOT_APP_ID/PASSWORD may have changed) + # 4. Rebuild Bot Framework adapter self._rebuild_adapter() - # 5. Reinitialise voice handler (ACS settings may have changed) + # 5. Reinitialise voice handler reinit_voice = request.app.get("_reinit_voice") if reinit_voice: reinit_voice() @@ -940,37 +411,9 @@ async def _handle_reload(self, request: web.Request) -> web.Response: and self._infra_store.telegram_configured ) if needs_bot: - bot_endpoint = os.environ.get("BOT_ENDPOINT", "") - tunnel_active = getattr(self._tunnel, "is_active", False) if self._tunnel else False - - if bot_endpoint: - # Static endpoint (ACA or other) -- no tunnel needed. - async def _deferred_static_bot() -> None: - await self._recreate_bot(endpoint_override=bot_endpoint) - - request.app["reload_bot_task"] = asyncio.create_task( - _deferred_static_bot() - ) - bot_task_started = True - elif self._tunnel and not tunnel_active: - from ..services.deployer import BotDeployer - - bot_app_id = BotDeployer._env("BOT_APP_ID") - if bot_app_id: - async def _deferred_docker_bot() -> None: - await self._start_tunnel_and_create_bot() - - request.app["reload_bot_task"] = asyncio.create_task( - _deferred_docker_bot() - ) - bot_task_started = True - elif self._tunnel and tunnel_active: - async def _deferred_recreate() -> None: - await self._recreate_bot() - - request.app["reload_bot_task"] = asyncio.create_task( - _deferred_recreate() - ) + coro = self._pick_bot_reload_coro() + if coro is not None: + request.app["reload_bot_task"] = asyncio.create_task(coro) bot_task_started = True logger.info( @@ -985,119 +428,55 @@ async def _deferred_recreate() -> None: "bot_task_started": bot_task_started, }) - async def _reconcile_deployments(self) -> None: - from ..services.resource_tracker import ResourceTracker - from ..util.async_helpers import run_sync - - try: - tracker = ResourceTracker(self._az, self._deploy_store) - cleaned = await run_sync(tracker.reconcile) - if cleaned: - logger.info( - "Startup reconcile: removed %d stale deployment(s): %s", - len(cleaned), ", ".join(c["deploy_id"] for c in cleaned), - ) - except Exception as exc: - logger.warning("Startup reconcile failed (non-fatal): %s", exc) - - async def _on_cleanup(self, _app: web.Application) -> None: - for key in ("scheduler_task", "proactive_task", "foundry_iq_task", "reconcile_task"): - task = _app.get(key) - if task and not task.done(): - task.cancel() - - if self._mode == ServerMode.combined: - if cfg.lockdown_mode: - logger.info("Lock Down Mode active -- skipping shutdown decommission") - elif self._infra_store.bot_configured and (cfg.env.read("BOT_NAME") or cfg.env.read("BOT_APP_ID")) and self._provisioner: - from ..util.async_helpers import run_sync - - logger.info("Shutdown: decommissioning infrastructure ...") - steps = await run_sync(self._provisioner.decommission) - for s in steps: - logger.info( - " decommission: %s = %s (%s)", - s.get("step"), s.get("status"), s.get("detail", ""), - ) - - if self._agent: - await self._agent.stop() - - -async def _foundry_iq_index_loop(store: object) -> None: - from ..services.foundry_iq import index_memories - from ..state.foundry_iq_config import FoundryIQConfigStore - from ..util.async_helpers import run_sync - - assert isinstance(store, FoundryIQConfigStore) - await asyncio.sleep(60) - while True: - try: - store._load() - schedule = store.config.index_schedule - if store.enabled and store.is_configured and schedule in _SCHEDULE_INTERVALS: - logger.info("Foundry IQ: running scheduled indexing (%s)...", schedule) - result = await run_sync(index_memories, store) - logger.info("Foundry IQ indexing: %s (indexed=%s)", result.get("status"), result.get("indexed", 0)) - interval = _SCHEDULE_INTERVALS.get(schedule, 86400) - except asyncio.CancelledError: - return - except Exception as exc: - logger.error("Foundry IQ index loop error: %s", exc, exc_info=True) - interval = 3600 - try: - await asyncio.sleep(interval) - except asyncio.CancelledError: - return + def _pick_bot_reload_coro(self) -> Any: + """Return the appropriate bot-reload coroutine, or ``None``.""" + bot_endpoint = os.environ.get("BOT_ENDPOINT", "") + tunnel_active = ( + getattr(self._tunnel, "is_active", False) + if self._tunnel + else False + ) + if bot_endpoint: + return lifecycle.recreate_bot( + provisioner=self._provisioner, az=self._az, + infra_store=self._infra_store, tunnel=self._tunnel, + rebuild_adapter=self._rebuild_adapter, + endpoint_override=bot_endpoint, + ) -async def _serve_media(req: web.Request) -> web.Response: - filename = req.match_info["filename"] - if ".." in filename or filename.startswith("/"): - return web.Response(status=403, text="Forbidden") - file_path = cfg.media_outgoing_sent_dir / filename - if not file_path.is_file(): - return web.Response(status=404, text="Not found") - content_type = ( - EXTENSION_TO_MIME.get(file_path.suffix.lower()) - or mimetypes.guess_type(file_path.name)[0] - or "application/octet-stream" - ) - return web.FileResponse(file_path, headers={"Content-Type": content_type}) + if self._tunnel and not tunnel_active: + from ..services.deployment.deployer import BotDeployer as _BD + if _BD._env("BOT_APP_ID"): + return lifecycle.start_tunnel_and_create_bot( + tunnel=self._tunnel, + provisioner=self._provisioner, + az=self._az, + infra_store=self._infra_store, + rebuild_adapter=self._rebuild_adapter, + ) -def _make_file_handler(fpath: Path): - async def handler(_req: web.Request) -> web.Response: - ct = mimetypes.guess_type(fpath.name)[0] or "application/octet-stream" - return web.FileResponse(fpath, headers={"Content-Type": ct}) - return handler + if self._tunnel and tunnel_active: + return lifecycle.recreate_bot( + provisioner=self._provisioner, az=self._az, + infra_store=self._infra_store, tunnel=self._tunnel, + rebuild_adapter=self._rebuild_adapter, + ) + return None -async def _serve_index(req: web.Request) -> web.Response: - index = _FRONTEND_DIR / "index.html" - if not index.exists(): - return web.Response(status=404, text="Not found") - html = index.read_text() - return web.Response( - text=html, - content_type="text/html", - headers={"Cache-Control": "no-cache, no-store, must-revalidate"}, - ) -async def _serve_spa_or_404(req: web.Request) -> web.Response: - if req.path.startswith("/api/"): - raise web.HTTPNotFound( - text='{"status":"error","message":"Unknown endpoint: ' - f'{req.method} {req.path}"' + '}', - content_type="application/json", - ) - return await _serve_index(req) +# -- CLI entry point ------------------------------------------------------- def main() -> None: + """Launch the server from the command line.""" import argparse + from .middleware import QuietAccessLogger + parser = argparse.ArgumentParser(description="Polyclaw server") parser.add_argument( "--admin-only", @@ -1116,10 +495,8 @@ def main() -> None: # Set the mode via env var so Settings.reload() picks it up. if args.admin_only: - import os os.environ["POLYCLAW_SERVER_MODE"] = "admin" elif args.runtime_only: - import os os.environ["POLYCLAW_SERVER_MODE"] = "runtime" logging.basicConfig( @@ -1138,7 +515,11 @@ def main() -> None: cfg.write_env(ADMIN_SECRET=secrets.token_urlsafe(24)) logger.info("Generated ADMIN_SECRET (persisted to .env)") - display_secret = cfg.admin_secret if cfg.admin_secret and not cfg.admin_secret.startswith("@kv:") else "" + display_secret = ( + cfg.admin_secret + if cfg.admin_secret and not cfg.admin_secret.startswith("@kv:") + else "" + ) if mode == ServerMode.runtime: admin_url = f"http://localhost:{port}" logger.info("Runtime endpoint: %s", admin_url) diff --git a/app/runtime/server/app_routes.py b/app/runtime/server/app_routes.py new file mode 100644 index 0000000..a5c30ee --- /dev/null +++ b/app/runtime/server/app_routes.py @@ -0,0 +1,230 @@ +"""Route registration helpers for AppFactory.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from aiohttp import web + +from ..config.settings import cfg +from .app_static import voice_handler +from .wiring import create_voice_handler + +if TYPE_CHECKING: + from ..services.cloud.azure import AzureCLI + from ..services.cloud.github import GitHubAuth + from ..services.deployment.aca_deployer import AcaDeployer + from ..services.deployment.deployer import BotDeployer + from ..services.deployment.provisioner import Provisioner + from ..services.tunnel import CloudflareTunnel + from ..state.deploy_state import DeployStateStore + from ..state.foundry_iq_config import FoundryIQConfigStore + from ..state.guardrails import GuardrailsConfigStore + from ..state.infra_config import InfraConfigStore + from ..state.mcp_config import McpConfigStore + from ..state.monitoring_config import MonitoringConfigStore + from ..state.proactive import ProactiveStore + from ..state.sandbox_config import SandboxConfigStore + from ..sandbox import SandboxExecutor + from ..messaging.proactive import ConversationReferenceStore + from ..state.session_store import SessionStore + from ..scheduler import Scheduler + from .bot_endpoint import BotEndpoint + + +def register_admin_routes( + router: web.UrlDispatcher, + *, + az: AzureCLI | None, + gh: GitHubAuth | None, + tunnel: CloudflareTunnel | None, + deployer: BotDeployer | None, + rebuild_adapter: Any, + infra_store: InfraConfigStore | None, + provisioner: Provisioner | None, + deploy_store: DeployStateStore | None, + aca_deployer: AcaDeployer | None, + sandbox_store: SandboxConfigStore | None, + sandbox_executor: SandboxExecutor | None, + foundry_iq_store: FoundryIQConfigStore | None, + monitoring_store: MonitoringConfigStore | None, + guardrails_store: GuardrailsConfigStore | None, +) -> None: + """Register routes available only in ``admin`` or ``combined`` mode.""" + from .setup import SetupRoutes, VoiceSetupRoutes + from .workspace import WorkspaceHandler + from .routes.content_safety_routes import ContentSafetyRoutes + from .routes.env_routes import EnvironmentRoutes + from .routes.foundry_iq_routes import FoundryIQRoutes + from .routes.network_routes import NetworkRoutes + from .routes.monitoring_routes import MonitoringRoutes + from .routes.sandbox_routes import SandboxRoutes + + SetupRoutes( + az, gh, tunnel, deployer, + rebuild_adapter, infra_store, + provisioner, deploy_store, + aca_deployer, + ).register(router) + + VoiceSetupRoutes(az, infra_store).register(router) + WorkspaceHandler().register(router) + EnvironmentRoutes(deploy_store, az).register(router) + SandboxRoutes(sandbox_store, sandbox_executor, az, deploy_store).register(router) + FoundryIQRoutes(foundry_iq_store, az, deploy_store).register(router) + NetworkRoutes(tunnel, az, sandbox_store, foundry_iq_store).register(router) + MonitoringRoutes(monitoring_store, az, deploy_store).register(router) + ContentSafetyRoutes(az, guardrails_store).register(router) + + from .routes.identity_routes import IdentityRoutes + + IdentityRoutes(az, guardrails_store).register(router) + + if az: + from .routes.security_preflight_routes import SecurityPreflightRoutes + from ..services.security.security_preflight import SecurityPreflightChecker + + SecurityPreflightRoutes(SecurityPreflightChecker(az)).register(router) + + +def register_runtime_routes( + app: web.Application, + *, + agent: Any, + session_store: SessionStore | None, + sandbox_executor: SandboxExecutor | None, + mcp_store: McpConfigStore | None, + guardrails_store: GuardrailsConfigStore | None, + scheduler: Scheduler | None, + proactive_store: ProactiveStore | None, + adapter: Any, + conv_store: ConversationReferenceStore | None, + bot_ep: BotEndpoint | None, + tunnel: CloudflareTunnel | None, + voice_routes: Any, + handle_reload: Any, +) -> None: + """Register routes available only in ``runtime`` or ``combined`` mode.""" + from ..registries.plugins import get_plugin_registry + from ..registries.skills import get_registry as get_skill_registry + from ..state.plugin_config import PluginConfigStore + from .chat import ChatHandler + from .routes.guardrails_routes import GuardrailsRoutes + from .routes.mcp_routes import McpRoutes + from .routes.plugin_routes import PluginRoutes + from .routes.proactive_routes import ProactiveRoutes + from .routes.profile_routes import ProfileRoutes + from .routes.scheduler_routes import SchedulerRoutes + from .routes.session_routes import SessionRoutes + from .routes.skill_routes import SkillRoutes + from .routes.tool_activity_routes import ToolActivityRoutes + + router = app.router + + router.add_post("/api/internal/reload", handle_reload) + + from .routes.network_routes import NetworkRoutes as _NR + + _nr_instance = _NR(tunnel) + router.add_get("/api/network/endpoints", _nr_instance._endpoints) + + hitl = agent.hitl_interceptor if agent else None + if hitl: + wire_hitl_services(app, hitl, guardrails_store) + + ChatHandler( + agent, + session_store=session_store, + sandbox_interceptor=sandbox_executor, + hitl_interceptor=hitl, + ).register(router) + + bot_ep.register(router) + register_voice_dynamic(app, voice_routes=voice_routes, agent=agent, tunnel=tunnel) + + SchedulerRoutes(scheduler).register(router) + SessionRoutes(session_store).register(router) + SkillRoutes(get_skill_registry()).register(router) + McpRoutes(mcp_store).register(router) + PluginRoutes(get_plugin_registry(), PluginConfigStore()).register(router) + ProfileRoutes().register(router) + GuardrailsRoutes( + guardrails_store, mcp_store, + skills_registry=get_skill_registry(), + ).register(router) + + from ..state.tool_activity_store import get_tool_activity_store + + ToolActivityRoutes(get_tool_activity_store(), session_store).register(router) + + ProactiveRoutes( + proactive_store, + adapter=adapter, + conv_store=conv_store, + app_id=cfg.bot_app_id, + ).register(router) + + +def wire_hitl_services( + app: web.Application, hitl: Any, guardrails_store: Any, +) -> None: + """Wire phone verifier, AITL reviewer, and prompt shield into HITL.""" + from ..agent.aitl import AitlReviewer + from ..agent.phone_verify import PhoneVerifier + from ..services.security.prompt_shield import PromptShieldService + + phone_verifier = PhoneVerifier(app) + hitl.set_phone_verifier(phone_verifier) + app["_phone_verifier"] = phone_verifier + + gcfg = guardrails_store.config + hitl.set_aitl_reviewer( + AitlReviewer(model=gcfg.aitl_model, spotlighting=gcfg.aitl_spotlighting), + ) + hitl.set_prompt_shield( + PromptShieldService( + endpoint=gcfg.content_safety_endpoint, mode=gcfg.filter_mode, + ), + ) + + +def register_voice_dynamic( + app: web.Application, + *, + voice_routes: Any, + agent: Any, + tunnel: Any, +) -> None: + """Register dynamic voice routes that delegate to the current handler.""" + app["_voice_handler"] = voice_routes + + def reinit_voice() -> None: + handler = create_voice_handler(agent, tunnel) + app["_voice_handler"] = handler + app["voice_configured"] = handler is not None + + app["_reinit_voice"] = reinit_voice + + router = app.router + router.add_post("/api/voice/call", voice_handler("_api_call")) + router.add_get("/api/voice/status", voice_handler("_api_status")) + # Legacy routes (kept for backwards compat) + router.add_post("/acs", voice_handler("_acs_callback", log_label="ACS callback")) + router.add_post("/acs/incoming", voice_handler("_acs_incoming", log_label="ACS incoming")) + router.add_get( + "/realtime-acs", + voice_handler("_ws_handler_acs", log_label="ACS media-streaming WS"), + ) + # Routes matching cfg.acs_callback_path / cfg.acs_media_streaming_websocket_path + router.add_post( + "/api/voice/acs-callback", + voice_handler("_acs_callback", log_label="ACS callback"), + ) + router.add_post( + "/api/voice/acs-callback/incoming", + voice_handler("_acs_incoming", log_label="ACS incoming"), + ) + router.add_get( + "/api/voice/media-streaming", + voice_handler("_ws_handler_acs", log_label="ACS media-streaming WS"), + ) diff --git a/app/runtime/server/app_static.py b/app/runtime/server/app_static.py new file mode 100644 index 0000000..70c2449 --- /dev/null +++ b/app/runtime/server/app_static.py @@ -0,0 +1,100 @@ +"""Static / SPA file handlers and voice route delegation.""" + +from __future__ import annotations + +import logging +import mimetypes +from collections.abc import Awaitable, Callable +from pathlib import Path + +from aiohttp import web + +from ..config.settings import cfg +from ..media import EXTENSION_TO_MIME + +logger = logging.getLogger(__name__) + +FRONTEND_DIR = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist" + +# -- Voice dynamic route handler factory ----------------------------------- + +_VOICE_NOT_CONFIGURED = { + "status": "error", + "message": ( + "Voice calling is not configured. Deploy ACS + " + "Azure OpenAI resources in the Voice Call section first." + ), +} + + +def voice_handler( + method_name: str, *, log_label: str = "", +) -> Callable[[web.Request], Awaitable[web.Response]]: + """Create a dynamic voice route handler that delegates to the app's handler.""" + + async def handler(req: web.Request) -> web.Response: + h = req.app.get("_voice_handler") + if log_label: + logger.info( + "%s hit: method=%s path=%s handler=%s", + log_label, req.method, req.path, + "configured" if h else "NONE", + ) + if h is None: + return web.json_response(_VOICE_NOT_CONFIGURED, status=400) + return await getattr(h, method_name)(req) + + return handler + + +# -- Static file handlers ------------------------------------------------- + + +async def serve_media(req: web.Request) -> web.Response: + """Serve an uploaded media file from the outgoing directory.""" + filename = req.match_info["filename"] + if ".." in filename or filename.startswith("/"): + return web.Response(status=403, text="Forbidden") + file_path = cfg.media_outgoing_sent_dir / filename + if not file_path.is_file(): + return web.Response(status=404, text="Not found") + content_type = ( + EXTENSION_TO_MIME.get(file_path.suffix.lower()) + or mimetypes.guess_type(file_path.name)[0] + or "application/octet-stream" + ) + return web.FileResponse(file_path, headers={"Content-Type": content_type}) + + +def make_file_handler(fpath: Path) -> Callable: + """Return a handler that serves a single static file.""" + + async def handler(_req: web.Request) -> web.Response: + ct = mimetypes.guess_type(fpath.name)[0] or "application/octet-stream" + return web.FileResponse(fpath, headers={"Content-Type": ct}) + + return handler + + +async def serve_index(req: web.Request) -> web.Response: + """Serve the frontend index.html with no-cache headers.""" + index = FRONTEND_DIR / "index.html" + if not index.exists(): + return web.Response(status=404, text="Not found") + html = index.read_text() + return web.Response( + text=html, + content_type="text/html", + headers={"Cache-Control": "no-cache, no-store, must-revalidate"}, + ) + + +async def serve_spa_or_404(req: web.Request) -> web.Response: + """Serve the SPA for non-API paths, 404 for unknown /api/ paths.""" + if req.path.startswith("/api/"): + raise web.HTTPNotFound( + text='{"status":"error","message":"Unknown endpoint: ' + f'{req.method} {req.path}"' + '}', + content_type="application/json", + ) + return await serve_index(req) diff --git a/app/runtime/server/chat.py b/app/runtime/server/chat.py index f5f9782..db482b5 100644 --- a/app/runtime/server/chat.py +++ b/app/runtime/server/chat.py @@ -109,22 +109,45 @@ async def get_suggestions(self, _req: web.Request) -> web.Response: async def _dispatch(self, ws: web.WebSocketResponse, data: dict) -> None: action = data.get("action", "") logger.info("[chat.dispatch] action=%s keys=%s", action, list(data.keys())) - if action == "new_session": - await self._agent.new_session() - session_id = str(uuid.uuid4()) - logger.info("[chat.dispatch] new session created: %s", session_id) - self._sessions.start_session(session_id, model=cfg.copilot_model) - await ws.send_json({"type": "session_created", "session_id": session_id}) - elif action == "resume_session": - await self._resume_session(ws, data.get("session_id", "")) - elif action == "send": - await self._send_prompt(ws, data) - elif action == "approve_tool": - await self._handle_tool_approval(ws, data) + + handler = self._ACTION_DISPATCH.get(action) + if handler is not None: + await handler(self, ws, data) else: logger.warning("[chat.dispatch] unknown action: %s", action) await ws.send_json({"type": "error", "content": f"Unknown action: {action}"}) + async def _handle_new_session( + self, ws: web.WebSocketResponse, _data: dict + ) -> None: + await self._agent.new_session() + session_id = str(uuid.uuid4()) + logger.info("[chat.dispatch] new session created: %s", session_id) + self._sessions.start_session(session_id, model=cfg.copilot_model) + await ws.send_json({"type": "session_created", "session_id": session_id}) + + async def _dispatch_resume( + self, ws: web.WebSocketResponse, data: dict + ) -> None: + await self._resume_session(ws, data.get("session_id", "")) + + async def _dispatch_send( + self, ws: web.WebSocketResponse, data: dict + ) -> None: + await self._send_prompt(ws, data) + + async def _dispatch_approve( + self, ws: web.WebSocketResponse, data: dict + ) -> None: + await self._handle_tool_approval(ws, data) + + _ACTION_DISPATCH: dict[str, Any] = { + "new_session": _handle_new_session, + "resume_session": _dispatch_resume, + "send": _dispatch_send, + "approve_tool": _dispatch_approve, + } + async def _send_prompt(self, ws: web.WebSocketResponse, data: dict) -> None: text = (data.get("text") or data.get("message") or "").strip() if not text: @@ -132,16 +155,12 @@ async def _send_prompt(self, ws: web.WebSocketResponse, data: dict) -> None: return session_id = data.get("session_id", "") - logger.info("[chat.send_prompt] text=%r session=%s", text[:80], session_id or "(none)") + logger.info( + "[chat.send_prompt] text=%r session=%s", + text[:80], session_id or "(none)", + ) - # Ensure session store tracks a session -- auto-create one if none is - # active so that messages are always persisted to disk. - if session_id and self._sessions.current_session_id != session_id: - self._sessions.start_session(session_id) - elif not self._sessions.current_session_id: - auto_id = str(uuid.uuid4()) - logger.info("[chat.send_prompt] no active session, auto-creating %s", auto_id) - self._sessions.start_session(auto_id, model=cfg.copilot_model) + self._ensure_active_session(session_id) # Slash command dispatch if text.startswith("/"): @@ -155,6 +174,62 @@ async def _send_prompt(self, ws: web.WebSocketResponse, data: dict) -> None: memory.record("user", text) chunks: list[str] = [] + on_delta, on_event = self._make_event_callbacks(ws, chunks) + self._bind_hitl(ws) + + logger.info("[chat.send_prompt] calling agent.send() ...") + with agent_span( + "chat.agent_turn", + attributes={ + "chat.prompt_length": len(text), + "chat.session_id": session_id or "", + }, + ): + try: + response = await self._agent.send( + text, + on_delta=lambda d: asyncio.ensure_future(on_delta(d)), + on_event=lambda t, d: asyncio.ensure_future( + on_event({"type": t, **d}), + ), + ) + except Exception: + logger.exception("[chat.send_prompt] agent.send() raised") + record_event("agent_error") + await ws.send_json({ + "type": "error", + "content": "Agent error -- check server logs", + }) + return + finally: + self._unbind_hitl() + full_text = "".join(chunks) or response or "" + set_span_attribute("chat.response_length", len(full_text)) + set_span_attribute("chat.chunk_count", len(chunks)) + + await self._finalize_response(ws, full_text, chunks, memory) + + # -- _send_prompt helpers ---------------------------------------------- + + def _ensure_active_session(self, session_id: str) -> None: + """Ensure the session store is tracking an active session.""" + if session_id and self._sessions.current_session_id != session_id: + self._sessions.start_session(session_id) + elif not self._sessions.current_session_id: + auto_id = str(uuid.uuid4()) + logger.info( + "[chat.send_prompt] no active session, auto-creating %s", + auto_id, + ) + self._sessions.start_session(auto_id, model=cfg.copilot_model) + + def _make_event_callbacks( + self, ws: web.WebSocketResponse, chunks: list[str], + ) -> tuple[ + Any, # on_delta coroutine + Any, # on_event coroutine + ]: + """Build the delta and event callback coroutines for agent.send.""" async def on_delta(delta: str) -> None: chunks.append(delta) @@ -163,7 +238,9 @@ async def on_delta(delta: str) -> None: async def on_event(event: dict[str, Any]) -> None: event_type = event.pop("type", "") if event_type == "sandbox_exec" and self._sandbox: - result = await self._sandbox.intercept({"type": event_type, **event}) + result = await self._sandbox.intercept( + {"type": event_type, **event}, + ) if result: await ws.send_json({"type": "sandbox_result", **result}) # Record tool activity for audit @@ -173,7 +250,9 @@ async def on_event(event: dict[str, Any]) -> None: tool_name = event.get("tool", "unknown") interaction_type = "" if self._hitl: - interaction_type = self._hitl.pop_resolved_strategy(tool_name) + interaction_type = ( + self._hitl.pop_resolved_strategy(tool_name) + ) self._tool_activity.record_start( session_id=self._sessions.current_session_id, tool=tool_name, @@ -188,60 +267,68 @@ async def on_event(event: dict[str, Any]) -> None: call_id=event.get("call_id", ""), result=event.get("result", ""), ) - await ws.send_json({"type": "event", "event": event_type, **event}) + await ws.send_json({ + "type": "event", "event": event_type, **event, + }) - # Bind the HITL emitter so approval requests reach the WebSocket - if self._hitl: - def hitl_emit(etype: str, payload: dict[str, Any]) -> None: - logger.info( - "[chat.hitl_emit] sending event=%s payload_keys=%s", - etype, list(payload.keys()), - ) - asyncio.ensure_future(ws.send_json({"type": "event", "event": etype, **payload})) - self._hitl.set_emit(hitl_emit) - self._hitl.set_execution_context("interactive") - self._hitl.set_model(cfg.copilot_model) - self._hitl.set_tool_activity(self._tool_activity) - self._hitl.set_session_id(self._sessions.current_session_id) + return on_delta, on_event + + def _bind_hitl(self, ws: web.WebSocketResponse) -> None: + """Bind the HITL emitter so approval requests reach the WebSocket.""" + if not self._hitl: + logger.info("[chat.send_prompt] no HITL interceptor available") + return + + def hitl_emit(etype: str, payload: dict[str, Any]) -> None: logger.info( - "[chat.send_prompt] HITL emitter bound: model=%s", - cfg.copilot_model, + "[chat.hitl_emit] sending event=%s payload_keys=%s", + etype, list(payload.keys()), + ) + asyncio.ensure_future( + ws.send_json({"type": "event", "event": etype, **payload}), ) - else: - logger.info("[chat.send_prompt] no HITL interceptor available") - logger.info("[chat.send_prompt] calling agent.send() ...") - with agent_span( - "chat.agent_turn", - attributes={"chat.prompt_length": len(text), "chat.session_id": session_id or ""}, - ): - try: - response = await self._agent.send( - text, - on_delta=lambda d: asyncio.ensure_future(on_delta(d)), - on_event=lambda t, d: asyncio.ensure_future(on_event({"type": t, **d})), - ) - except Exception: - logger.exception("[chat.send_prompt] agent.send() raised") - record_event("agent_error") - await ws.send_json({"type": "error", "content": "Agent error -- check server logs"}) - return - finally: - if self._hitl: - self._hitl.clear_emit() - full_text = "".join(chunks) or response or "" - set_span_attribute("chat.response_length", len(full_text)) - set_span_attribute("chat.chunk_count", len(chunks)) - logger.info("[chat.send_prompt] response complete, len=%d, chunks=%d", len(full_text), len(chunks)) + self._hitl.bind_turn( + emit=hitl_emit, + execution_context="interactive", + model=cfg.copilot_model, + tool_activity=self._tool_activity, + session_id=self._sessions.current_session_id, + ) + logger.info( + "[chat.send_prompt] HITL emitter bound: model=%s", + cfg.copilot_model, + ) + + def _unbind_hitl(self) -> None: + """Clear the HITL emitter after a turn completes.""" + if self._hitl: + self._hitl.unbind_turn() + + async def _finalize_response( + self, + ws: web.WebSocketResponse, + full_text: str, + chunks: list[str], + memory: Any, + ) -> None: + """Log, persist, and send the final response artifacts.""" + logger.info( + "[chat.send_prompt] response complete, len=%d, chunks=%d", + len(full_text), len(chunks), + ) if not full_text: - logger.warning("[chat.send_prompt] empty response -- model may have timed out") + logger.warning( + "[chat.send_prompt] empty response -- " + "model may have timed out", + ) await ws.send_json({ "type": "error", "content": ( "The model did not respond. " - "This can happen when the model is overloaded or the session " - "is stale. Please try again." + "This can happen when the model is overloaded or " + "the session is stale. Please try again." ), }) @@ -254,7 +341,10 @@ def hitl_emit(etype: str, payload: dict[str, Any]) -> None: if outgoing: await ws.send_json({"type": "media", "files": outgoing}) if cards: - await ws.send_json({"type": "cards", "cards": [attachment_to_dict(c) for c in cards]}) + await ws.send_json({ + "type": "cards", + "cards": [attachment_to_dict(c) for c in cards], + }) await ws.send_json({"type": "done"}) async def _try_command( diff --git a/app/runtime/server/lifecycle.py b/app/runtime/server/lifecycle.py new file mode 100644 index 0000000..cff0e94 --- /dev/null +++ b/app/runtime/server/lifecycle.py @@ -0,0 +1,371 @@ +"""Application lifecycle -- startup and cleanup hooks.""" + +from __future__ import annotations + +import asyncio +import logging +import os +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, Any + +from aiohttp import web + +from ..config.settings import ServerMode, cfg +from .wiring import create_adapter, create_voice_handler + +if TYPE_CHECKING: + pass + +logger = logging.getLogger(__name__) + +_SCHEDULE_INTERVALS = {"hourly": 3600, "daily": 86400} + + +async def on_startup_runtime( + app: web.Application, + *, + mode: ServerMode, + adapter: object, + bot: object | None, + bot_ep: object | None, + conv_store: object | None, + agent: object | None, + tunnel: object | None, + infra_store: object, + provisioner: object | None, + az: object | None, + monitoring_store: object, + session_store: object | None, + foundry_iq_store: object, + scheduler: object | None, + rebuild_adapter: Callable, + make_notify: Callable[[], Callable[[str], Awaitable[bool]]], +) -> None: + """Start background tasks and bot infrastructure for the runtime.""" + from ..messaging.proactive_loop import proactive_delivery_loop + from ..scheduler import scheduler_loop + from ..services.otel import configure_otel + + # Bootstrap OTel if monitoring is configured + mon = monitoring_store + if mon.is_configured: + configure_otel( + mon.connection_string, + sampling_ratio=mon.config.sampling_ratio, + enable_live_metrics=mon.config.enable_live_metrics, + ) + + rebuild_adapter() + + app["scheduler_task"] = asyncio.create_task(scheduler_loop()) + app["proactive_task"] = asyncio.create_task( + proactive_delivery_loop(make_notify(), session_store=session_store), + ) + app["foundry_iq_task"] = asyncio.create_task( + _foundry_iq_index_loop(foundry_iq_store), + ) + + logger.info( + "[startup.runtime] mode=%s lockdown=%s bot_configured=%s " + "telegram_configured=%s tunnel=%s provisioner=%s az=%s", + mode.value, cfg.lockdown_mode, + infra_store.bot_configured if infra_store else "", + infra_store.telegram_configured if infra_store else "", + tunnel is not None, + provisioner is not None, + az is not None, + ) + + if cfg.lockdown_mode: + logger.info("Lock Down Mode active -- skipping infrastructure provisioning") + return + + bot_endpoint = os.environ.get("BOT_ENDPOINT", "") + + if mode != ServerMode.combined: + github_token = cfg.github_token + if not github_token: + logger.warning( + "[startup.runtime] Setup incomplete -- missing GITHUB_TOKEN. " + "Complete the setup wizard in the admin container, " + "then recreate the agent container.", + ) + return + + needs_bot = ( + infra_store.bot_configured + and infra_store.telegram_configured + ) + + if mode == ServerMode.combined: + if infra_store.bot_configured and provisioner: + from ..util.async_helpers import run_sync + + logger.info("Startup: provisioning infrastructure from config ...") + steps = await run_sync(provisioner.provision) + rebuild_adapter() + for s in steps: + logger.info( + " provision: %s = %s (%s)", + s.get("step"), s.get("status"), s.get("detail", ""), + ) + if needs_bot and tunnel: + await start_tunnel_and_create_bot( + tunnel=tunnel, provisioner=provisioner, az=az, + infra_store=infra_store, rebuild_adapter=rebuild_adapter, + ) + + elif bot_endpoint: + cfg.reload() + rebuild_adapter() + if needs_bot: + logger.info("Static bot endpoint: %s", bot_endpoint) + await recreate_bot( + provisioner=provisioner, az=az, infra_store=infra_store, + tunnel=tunnel, rebuild_adapter=rebuild_adapter, + endpoint_override=bot_endpoint, + ) + else: + logger.info("No messaging channels configured -- skipping bot service") + + else: + if needs_bot and tunnel: + from ..services.deployment.deployer import BotDeployer + + bot_app_id = BotDeployer._env("BOT_APP_ID") + if not bot_app_id: + logger.warning( + "Telegram configured but BOT_APP_ID missing -- " + "run Infrastructure Deploy in the admin wizard first" + ) + else: + await start_tunnel_and_create_bot( + tunnel=tunnel, provisioner=provisioner, az=az, + infra_store=infra_store, rebuild_adapter=rebuild_adapter, + ) + else: + reasons = [] + if not infra_store.bot_configured: + reasons.append("bot not configured") + if not infra_store.telegram_configured: + reasons.append("no channels configured") + if not tunnel: + reasons.append("no tunnel") + logger.info( + "Skipping bot service: %s", + ", ".join(reasons) or "no reason", + ) + + +async def on_startup_admin( + app: web.Application, + *, + az: object | None, + deploy_store: object, + guardrails_store: object, +) -> None: + """Admin startup: reconcile stale deployments and RBAC.""" + if az: + app["reconcile_task"] = asyncio.create_task( + _reconcile_deployments(az, deploy_store), + ) + app["cs_rbac_task"] = asyncio.create_task( + _ensure_content_safety_rbac(az, guardrails_store), + ) + + +async def on_cleanup( + app: web.Application, + *, + mode: ServerMode, + infra_store: object, + provisioner: object | None, + agent: object | None, +) -> None: + """Cancel background tasks and decommission infrastructure on shutdown.""" + for key in ("scheduler_task", "proactive_task", "foundry_iq_task", "reconcile_task"): + task = app.get(key) + if task and not task.done(): + task.cancel() + + if mode == ServerMode.combined: + if cfg.lockdown_mode: + logger.info("Lock Down Mode active -- skipping shutdown decommission") + elif ( + infra_store.bot_configured + and (cfg.env.read("BOT_NAME") or cfg.env.read("BOT_APP_ID")) + and provisioner + ): + from ..util.async_helpers import run_sync + + logger.info("Shutdown: decommissioning infrastructure ...") + steps = await run_sync(provisioner.decommission) + for s in steps: + logger.info( + " decommission: %s = %s (%s)", + s.get("step"), s.get("status"), s.get("detail", ""), + ) + + if agent: + await agent.stop() + + +# -- Bot infrastructure helpers ------------------------------------------- + +async def recreate_bot( + *, + provisioner: object | None, + az: object | None, + infra_store: object, + tunnel: object | None, + rebuild_adapter: Callable, + endpoint_override: str | None = None, +) -> None: + """Recreate the bot service endpoint.""" + from ..util.async_helpers import run_sync + + logger.info( + "[recreate_bot] provisioner=%s az=%s bot_configured=%s endpoint_override=%s", + provisioner is not None, + az is not None, + infra_store.bot_configured if infra_store else "?", + endpoint_override, + ) + if not (provisioner and az and infra_store.bot_configured): + logger.warning( + "[recreate_bot] precondition failed -- provisioner=%s az=%s bot_configured=%s", + provisioner is not None, + az is not None, + infra_store.bot_configured if infra_store else "?", + ) + return + + tunnel_url = endpoint_override or getattr(tunnel, "url", None) + if not tunnel_url: + logger.warning("Bot recreate: no endpoint URL available -- skipping") + return + + endpoint = tunnel_url + logger.info("Bot recreate: endpoint %s", endpoint) + try: + steps = await run_sync(provisioner.recreate_endpoint, endpoint) + rebuild_adapter() + for s in steps: + logger.info( + " recreate: %s = %s (%s)", + s.get("step"), s.get("status"), s.get("detail", ""), + ) + except Exception as exc: + logger.warning("Bot recreate: error -- %s", exc, exc_info=True) + + +async def start_tunnel_and_create_bot( + *, + tunnel: object, + provisioner: object | None, + az: object | None, + infra_store: object, + rebuild_adapter: Callable, +) -> None: + """Start the Cloudflare tunnel and recreate the bot service.""" + from ..util.async_helpers import run_sync + + logger.info("Starting tunnel for bot service endpoint ...") + tunnel_url = tunnel.url + if not tunnel_url and not tunnel.is_active: + max_retries = 5 + for attempt in range(1, max_retries + 1): + result = await run_sync(tunnel.start, cfg.admin_port) + if result: + logger.info("Tunnel started at %s", result.value) + break + if attempt < max_retries: + logger.warning( + "Tunnel failed (attempt %d/%d): %s -- retrying in %ds ...", + attempt, max_retries, + result.message if result else "unknown", + 2 * attempt, + ) + await asyncio.sleep(2 * attempt) + else: + logger.error( + "Tunnel failed after %d attempts: %s", + max_retries, + result.message if result else "unknown", + ) + return + + rebuild_adapter() + await recreate_bot( + provisioner=provisioner, az=az, infra_store=infra_store, + tunnel=tunnel, rebuild_adapter=rebuild_adapter, + ) + + +# -- Background loops ----------------------------------------------------- + +async def _foundry_iq_index_loop(store: object) -> None: + from ..services.foundry_iq import index_memories + from ..state.foundry_iq_config import FoundryIQConfigStore + from ..util.async_helpers import run_sync + + assert isinstance(store, FoundryIQConfigStore) + await asyncio.sleep(60) + while True: + try: + store._load() + schedule = store.config.index_schedule + if store.enabled and store.is_configured and schedule in _SCHEDULE_INTERVALS: + logger.info("Foundry IQ: running scheduled indexing (%s)...", schedule) + result = await run_sync(index_memories, store) + logger.info( + "Foundry IQ indexing: %s (indexed=%s)", + result.get("status"), result.get("indexed", 0), + ) + interval = _SCHEDULE_INTERVALS.get(schedule, 86400) + except asyncio.CancelledError: + return + except Exception as exc: + logger.error("Foundry IQ index loop error: %s", exc, exc_info=True) + interval = 3600 + try: + await asyncio.sleep(interval) + except asyncio.CancelledError: + return + + +async def _reconcile_deployments(az: object, deploy_store: object) -> None: + from ..services.resource_tracker import ResourceTracker + from ..util.async_helpers import run_sync + + try: + tracker = ResourceTracker(az, deploy_store) + cleaned = await run_sync(tracker.reconcile) + if cleaned: + logger.info( + "Startup reconcile: removed %d stale deployment(s): %s", + len(cleaned), ", ".join(c["deploy_id"] for c in cleaned), + ) + except Exception as exc: + logger.warning("Startup reconcile failed (non-fatal): %s", exc) + + +async def _ensure_content_safety_rbac(az: object, guardrails_store: object) -> None: + from .routes.content_safety_routes import ContentSafetyRoutes + + try: + routes = ContentSafetyRoutes( + az=az, + guardrails_store=guardrails_store, + ) + steps = await routes.ensure_rbac() + for s in steps: + logger.info( + "[startup.cs_rbac] %s = %s (%s)", + s.get("step"), s.get("status"), s.get("detail", ""), + ) + except Exception: + logger.warning( + "[startup.cs_rbac] Content Safety RBAC check failed", + exc_info=True, + ) diff --git a/app/runtime/server/middleware.py b/app/runtime/server/middleware.py new file mode 100644 index 0000000..a3e32e7 --- /dev/null +++ b/app/runtime/server/middleware.py @@ -0,0 +1,120 @@ +"""HTTP middleware -- auth, lockdown, and tunnel restrictions.""" + +from __future__ import annotations + +import hmac +import logging + +from aiohttp import web +from aiohttp.abc import AbstractAccessLogger + +from ..config.settings import cfg + +logger = logging.getLogger(__name__) + +_QUIET_PATHS = frozenset({"/api/setup/status", "/health"}) + + +class QuietAccessLogger(AbstractAccessLogger): + """Demotes polling-endpoint and noisy log entries to DEBUG.""" + + def log(self, request: web.BaseRequest, response: web.StreamResponse, time: float) -> None: + status = response.status + if request.path in _QUIET_PATHS or status == 401 or status in (502, 503): + level = logging.DEBUG + else: + level = logging.INFO + self.logger.log( + level, + "%s %s %s %s %.3fs", + request.remote, + request.method, + request.path, + status, + time, + ) + + +_PUBLIC_PREFIXES = ( + "/health", + "/api/messages", + "/acs", + "/realtime-acs", + "/api/voice/acs-callback", + "/api/voice/media-streaming", +) +_PUBLIC_EXACT = ("/api/auth/check",) + +# Tunnel restrictions and lockdown share the same base set as public prefixes; +# lockdown adds one extra path. +_TUNNEL_ALLOWED_PREFIXES = _PUBLIC_PREFIXES +_LOCKDOWN_ALLOWED_PREFIXES = _PUBLIC_PREFIXES + ("/api/setup/lockdown",) + +_CF_HEADERS = ("cf-connecting-ip", "cf-ray", "cf-ipcountry") + + +@web.middleware +async def lockdown_middleware(request: web.Request, handler): # type: ignore[type-arg] + """Block all admin panel routes when lockdown mode is active.""" + if not cfg.lockdown_mode: + return await handler(request) + if any(request.path.startswith(p) for p in _LOCKDOWN_ALLOWED_PREFIXES): + return await handler(request) + return web.json_response( + { + "status": "locked", + "message": ( + "Lock Down Mode is active. The admin panel is disabled. " + "Use /lockdown off via the bot to restore access." + ), + }, + status=403, + ) + + +@web.middleware +async def tunnel_restriction_middleware(request: web.Request, handler): # type: ignore[type-arg] + """Restrict Cloudflare-tunnelled requests to bot-only endpoints.""" + if not cfg.tunnel_restricted: + return await handler(request) + is_tunnel = any(request.headers.get(h) for h in _CF_HEADERS) + if not is_tunnel: + return await handler(request) + if any(request.path.startswith(p) for p in _TUNNEL_ALLOWED_PREFIXES): + return await handler(request) + return web.json_response({"status": "forbidden"}, status=403) + + +@web.middleware +async def auth_middleware(request: web.Request, handler): # type: ignore[type-arg] + """Require Bearer token on ``/api/*`` endpoints (except public ones).""" + secret = cfg.admin_secret + if not secret: + return await handler(request) + + path = request.path + + # Only protect /api/* endpoints (except public ones); frontend assets are public + if not path.startswith("/api/"): + return await handler(request) + + if path in _PUBLIC_EXACT or any(path.startswith(p) for p in _PUBLIC_PREFIXES): + return await handler(request) + + auth = request.headers.get("Authorization", "") + expected = f"Bearer {secret}" + if hmac.compare_digest(auth, expected): + return await handler(request) + + token_param = request.query.get("token", "") + if token_param and hmac.compare_digest(token_param, secret): + return await handler(request) + + secret_param = request.query.get("secret", "") + if secret_param and hmac.compare_digest(secret_param, secret): + return await handler(request) + + return web.json_response( + {"status": "unauthorized", "message": "Invalid or missing admin secret"}, + status=401, + ) diff --git a/app/runtime/server/routes/__init__.py b/app/runtime/server/routes/__init__.py index 16c55ac..ad887cd 100644 --- a/app/runtime/server/routes/__init__.py +++ b/app/runtime/server/routes/__init__.py @@ -2,26 +2,40 @@ from __future__ import annotations +from .content_safety_routes import ContentSafetyRoutes from .env_routes import EnvironmentRoutes from .foundry_iq_routes import FoundryIQRoutes +from .guardrails_routes import GuardrailsRoutes +from .identity_routes import IdentityRoutes from .mcp_routes import McpRoutes +from .monitoring_routes import MonitoringRoutes +from .network_routes import NetworkRoutes from .plugin_routes import PluginRoutes from .proactive_routes import ProactiveRoutes from .profile_routes import ProfileRoutes from .sandbox_routes import SandboxRoutes from .scheduler_routes import SchedulerRoutes +from .security_preflight_routes import SecurityPreflightRoutes from .session_routes import SessionRoutes from .skill_routes import SkillRoutes +from .tool_activity_routes import ToolActivityRoutes __all__ = [ + "ContentSafetyRoutes", "EnvironmentRoutes", "FoundryIQRoutes", + "GuardrailsRoutes", + "IdentityRoutes", "McpRoutes", + "MonitoringRoutes", + "NetworkRoutes", "PluginRoutes", "ProactiveRoutes", "ProfileRoutes", "SandboxRoutes", "SchedulerRoutes", + "SecurityPreflightRoutes", "SessionRoutes", "SkillRoutes", + "ToolActivityRoutes", ] diff --git a/app/runtime/server/routes/_helpers.py b/app/runtime/server/routes/_helpers.py new file mode 100644 index 0000000..dbe9eda --- /dev/null +++ b/app/runtime/server/routes/_helpers.py @@ -0,0 +1,24 @@ +"""Shared helpers for route handlers.""" + +from __future__ import annotations + +from typing import Any + +from aiohttp import web + + +def no_az() -> web.Response: + """Return a standard error when Azure CLI is unavailable.""" + return web.json_response( + {"status": "error", "message": "Azure CLI not available"}, status=500 + ) + + +def fail_response(steps: list[dict[str, Any]]) -> web.Response: + """Return a standard provisioning-failure response with step details.""" + failed = [s for s in steps if s.get("status") == "failed"] + msg = failed[0].get("detail", "Unknown error") if failed else "Unknown error" + return web.json_response( + {"status": "error", "steps": steps, "message": f"Provisioning failed: {msg}"}, + status=500, + ) diff --git a/app/runtime/server/routes/content_safety_routes.py b/app/runtime/server/routes/content_safety_routes.py index 2a00de5..eb5a2d4 100644 --- a/app/runtime/server/routes/content_safety_routes.py +++ b/app/runtime/server/routes/content_safety_routes.py @@ -9,9 +9,9 @@ from aiohttp import web from ...config.settings import cfg -from ...services.azure import AzureCLI -from ...services.prompt_shield import PromptShieldService -from ...state.guardrails_config import GuardrailsConfigStore +from ...services.cloud.azure import AzureCLI +from ...services.security.prompt_shield import PromptShieldService +from ...state.guardrails import GuardrailsConfigStore from ...util.async_helpers import run_sync logger = logging.getLogger(__name__) diff --git a/app/runtime/server/routes/env_routes.py b/app/runtime/server/routes/env_routes.py index 33ec200..c109155 100644 --- a/app/runtime/server/routes/env_routes.py +++ b/app/runtime/server/routes/env_routes.py @@ -6,11 +6,12 @@ from aiohttp import web -from ...services.azure import AzureCLI -from ...services.misconfig_checker import MisconfigChecker +from ...services.cloud.azure import AzureCLI +from ...services.security.misconfig_checker import MisconfigChecker from ...services.resource_tracker import ResourceTracker from ...state.deploy_state import DeployStateStore from ...util.async_helpers import run_sync +from ._helpers import no_az as _no_az logger = logging.getLogger(__name__) @@ -118,8 +119,3 @@ async def _misconfig_check(self, req: web.Request) -> web.Response: result = await run_sync(checker.check_all, resource_groups) return web.json_response(MisconfigChecker.to_dict(result)) - -def _no_az() -> web.Response: - return web.json_response( - {"status": "error", "message": "Azure CLI not available"}, status=500 - ) diff --git a/app/runtime/server/routes/foundry_iq_routes.py b/app/runtime/server/routes/foundry_iq_routes.py index 23ef487..def92be 100644 --- a/app/runtime/server/routes/foundry_iq_routes.py +++ b/app/runtime/server/routes/foundry_iq_routes.py @@ -8,7 +8,7 @@ from aiohttp import web -from ...services.azure import AzureCLI +from ...services.cloud.azure import AzureCLI from ...services.foundry_iq import ( delete_index, ensure_index, @@ -21,6 +21,7 @@ from ...state.deploy_state import DeployStateStore from ...state.foundry_iq_config import FoundryIQConfigStore from ...util.async_helpers import run_sync +from ._helpers import fail_response as _fail_response, no_az as _no_az logger = logging.getLogger(__name__) @@ -436,15 +437,3 @@ async def _deploy_model( }) return deployment_name - -def _no_az() -> web.Response: - return web.json_response( - {"status": "error", "message": "Azure CLI not available"}, status=500 - ) - - -def _fail_response(steps: list[dict[str, Any]]) -> web.Response: - return web.json_response( - {"status": "error", "message": "Provisioning failed", "steps": steps}, - status=500, - ) diff --git a/app/runtime/server/routes/guardrails_routes.py b/app/runtime/server/routes/guardrails_routes.py index a137041..50a1ce1 100644 --- a/app/runtime/server/routes/guardrails_routes.py +++ b/app/runtime/server/routes/guardrails_routes.py @@ -3,31 +3,24 @@ from __future__ import annotations import logging +from collections.abc import Callable from typing import Any from aiohttp import web -from ...agent.tools import get_all_tools from ...registries.skills import SkillRegistry -from ...state.guardrails_config import ( +from ...state.guardrails import ( GuardrailsConfigStore, list_model_tiers, list_presets, ) from ...state.mcp_config import McpConfigStore - -logger = logging.getLogger(__name__) - -_BUILTIN_SDK_TOOLS: list[dict[str, str]] = [ - {"name": "create", "source": "sdk", "description": "Create a new file"}, - {"name": "edit", "source": "sdk", "description": "Edit an existing file"}, - {"name": "view", "source": "sdk", "description": "View file contents"}, - {"name": "grep", "source": "sdk", "description": "Search file contents"}, - {"name": "glob", "source": "sdk", "description": "Find files by pattern"}, - {"name": "run", "source": "sdk", "description": "Run a shell command"}, - {"name": "bash", "source": "sdk", "description": "Run a bash command"}, - {"name": "report_intent", "source": "sdk", "description": "Log agent intent (always auto-approved)"}, -] +from .guardrails_routes_meta import ( + collect_tools, + get_template_handler, + list_contexts_handler, + list_templates_handler, +) class GuardrailsRoutes: @@ -57,81 +50,87 @@ def register(self, router: web.UrlDispatcher) -> None: router.add_post("/api/guardrails/model-columns", self._add_model_column) router.add_delete("/api/guardrails/model-columns/{model}", self._remove_model_column) router.add_put("/api/guardrails/model-policies/{model}/{ctx}/{tool_id}", self._set_model_policy) - router.add_get("/api/guardrails/contexts", self._list_contexts) + router.add_get("/api/guardrails/contexts", list_contexts_handler) router.add_get("/api/guardrails/presets", self._list_presets) router.add_post("/api/guardrails/presets/{preset_id}", self._apply_preset) router.add_post("/api/guardrails/set-all", self._set_all) router.add_post("/api/guardrails/model-defaults", self._apply_model_defaults) router.add_get("/api/guardrails/model-tiers", self._list_model_tiers) - router.add_get("/api/guardrails/templates", self._list_templates) - router.add_get("/api/guardrails/templates/{name}", self._get_template) + router.add_get("/api/guardrails/templates", list_templates_handler) + router.add_get("/api/guardrails/templates/{name}", get_template_handler) router.add_get("/api/guardrails/background-agents", self._list_background_agents) router.add_get("/api/guardrails/policy-yaml", self._get_policy_yaml) router.add_put("/api/guardrails/policy-yaml", self._put_policy_yaml) + @staticmethod + def _apply_validated_field( + data: dict[str, Any], + key: str, + setter: Callable[[Any], None], + ) -> web.Response | None: + """Apply a data field via *setter*, returning a 400 on ValueError.""" + if key not in data: + return None + try: + setter(data[key]) + except ValueError as exc: + return web.json_response( + {"status": "error", "message": str(exc)}, status=400, + ) + return None + async def _get_config(self, _req: web.Request) -> web.Response: return web.json_response({"status": "ok", **self._store.to_dict()}) async def _update_config(self, req: web.Request) -> web.Response: data = await req.json() - # Accept both frontend ('enabled') and backend ('hitl_enabled') field names. + + # Boolean fields (no validation needed) if "enabled" in data: self._store.set_hitl_enabled(bool(data["enabled"])) if "hitl_enabled" in data: self._store.set_hitl_enabled(bool(data["hitl_enabled"])) - # Accept both 'default_strategy' (frontend) and 'default_action' (backend). - if "default_strategy" in data: - try: - self._store.set_default_action(data["default_strategy"]) - except ValueError as exc: - return web.json_response( - {"status": "error", "message": str(exc)}, status=400 - ) - if "default_action" in data: - try: - self._store.set_default_action(data["default_action"]) - except ValueError as exc: - return web.json_response( - {"status": "error", "message": str(exc)}, status=400 - ) - # Accept both 'hitl_channel' (frontend) and 'default_channel' (backend). - if "hitl_channel" in data: - try: - self._store.set_default_channel(data["hitl_channel"]) - except ValueError as exc: - return web.json_response( - {"status": "error", "message": str(exc)}, status=400 - ) - if "default_channel" in data: - try: - self._store.set_default_channel(data["default_channel"]) - except ValueError as exc: - return web.json_response( - {"status": "error", "message": str(exc)}, status=400 - ) + + # Validated fields -- accept both frontend and backend key names + for key in ("default_strategy", "default_action"): + err = self._apply_validated_field( + data, key, self._store.set_default_action, + ) + if err: + return err + for key in ("hitl_channel", "default_channel"): + err = self._apply_validated_field( + data, key, self._store.set_default_channel, + ) + if err: + return err + err = self._apply_validated_field( + data, "filter_mode", self._store.set_filter_mode, + ) + if err: + return err + + # Simple fields (no validation) if "phone_number" in data: self._store.set_phone_number(data["phone_number"]) if "aitl_model" in data: self._store.set_aitl_model(data["aitl_model"]) if "aitl_spotlighting" in data: self._store.set_aitl_spotlighting(bool(data["aitl_spotlighting"])) - if "filter_mode" in data: - try: - self._store.set_filter_mode(data["filter_mode"]) - except ValueError as exc: - return web.json_response( - {"status": "error", "message": str(exc)}, status=400 - ) if "content_safety_endpoint" in data: self._store.set_content_safety_endpoint(data["content_safety_endpoint"]) + + # Context defaults (batch update) if "context_defaults" in data: for ctx, strategy in data["context_defaults"].items(): try: self._store.set_context_default(ctx, strategy) except ValueError as exc: return web.json_response( - {"status": "error", "message": str(exc)}, status=400 + {"status": "error", "message": str(exc)}, + status=400, ) + # Single context_default update (used by Background Agents tab) if "context_default" in data: cd = data["context_default"] @@ -143,10 +142,12 @@ async def _update_config(self, req: web.Request) -> web.Response: self._store.set_context_default(ctx, strategy) except ValueError as exc: return web.json_response( - {"status": "error", "message": str(exc)}, status=400 + {"status": "error", "message": str(exc)}, + status=400, ) else: self._store.remove_context_default(ctx) + return web.json_response({"status": "ok", **self._store.to_dict()}) async def _list_rules(self, _req: web.Request) -> web.Response: @@ -250,7 +251,7 @@ async def _delete_rule(self, req: web.Request) -> web.Response: async def _list_tools(self, _req: web.Request) -> web.Response: """Return all tools and MCP servers available to the agent.""" - tools = self._collect_tools() + tools = collect_tools() mcps = self._collect_mcps() return web.json_response({ "status": "ok", @@ -261,7 +262,7 @@ async def _list_tools(self, _req: web.Request) -> web.Response: async def _list_inventory(self, _req: web.Request) -> web.Response: """Return a unified tool inventory for the policy matrix UI.""" inventory: list[dict[str, Any]] = [] - for t in self._collect_tools(): + for t in collect_tools(): inventory.append({ "id": t["name"], "name": t["name"], @@ -339,22 +340,6 @@ async def _set_model_policy(self, req: web.Request) -> web.Response: ) return web.json_response({"status": "ok"}) - def _collect_tools(self) -> list[dict[str, Any]]: - """Gather custom tools defined via @define_tool + built-in SDK tools.""" - result: list[dict[str, Any]] = [] - for t in get_all_tools(): - name = getattr(t, "name", "") or getattr(t, "__name__", "unknown") - desc = getattr(t, "description", "") or "" - # Avoid using the class-level __doc__ which is the Tool repr - if not desc and hasattr(t, "__doc__") and t.__doc__: - first_line = t.__doc__.strip().split("\n")[0] - if not first_line.startswith("Tool("): - desc = first_line - result.append({"name": name, "source": "custom", "description": desc}) - for entry in _BUILTIN_SDK_TOOLS: - result.append(dict(entry)) - return result - def _collect_mcps(self) -> list[dict[str, Any]]: """Gather configured MCP servers.""" return [ @@ -380,30 +365,6 @@ def _collect_skills(self) -> list[dict[str, Any]]: for s in self._skills.list_installed() ] - async def _list_contexts(self, _req: web.Request) -> web.Response: - """Return available execution contexts, HITL channels, and strategies.""" - return web.json_response({ - "status": "ok", - "contexts": [ - {"id": "interactive", "label": "Interactive", "description": "User is chatting via the web UI or TUI"}, - {"id": "background", "label": "Background", "description": "Scheduled tasks and proactive loop"}, - {"id": "voice", "label": "Voice", "description": "Realtime voice call sessions"}, - {"id": "api", "label": "API", "description": "External API-triggered executions"}, - ], - "channels": [ - {"id": "chat", "label": "Chat", "description": "In-session WebSocket approval prompt"}, - {"id": "phone", "label": "Phone Call", "description": "Outbound phone call verification via ACS"}, - ], - "strategies": [ - {"id": "allow", "label": "Allow", "description": "Pass through without review", "color": "var(--ok)"}, - {"id": "deny", "label": "Deny", "description": "Block immediately", "color": "var(--err)"}, - {"id": "hitl", "label": "HITL", "description": "Human-in-the-loop approval via chat", "color": "var(--blue)"}, - {"id": "pitl", "label": "PITL (Experimental)", "description": "Phone-in-the-loop approval via outbound phone call (experimental)", "color": "var(--cyan, #22d3ee)"}, - {"id": "aitl", "label": "AITL", "description": "AI-in-the-loop: background reviewer agent decides", "color": "var(--gold)"}, - {"id": "filter", "label": "Filter", "description": "Content Safety Prompt Shields injection detection", "color": "var(--purple, #a78bfa)"}, - ], - }) - async def _list_presets(self, _req: web.Request) -> web.Response: """Return available preset definitions with model-tier metadata.""" return web.json_response({ @@ -466,66 +427,9 @@ async def _list_model_tiers(self, _req: web.Request) -> web.Response: "models": list_model_tiers(), }) - async def _list_templates(self, _req: web.Request) -> web.Response: - """Return the list of prompt template names.""" - from pathlib import Path as _Path - - from ...agent.prompt import _TEMPLATES_DIR - - templates: list[dict[str, str]] = [] - if _TEMPLATES_DIR.is_dir(): - for f in sorted(_TEMPLATES_DIR.iterdir()): - if f.suffix == ".md": - templates.append({ - "name": f.name, - "size": str(f.stat().st_size), - }) - # Also include SOUL.md if it exists - from ...config.settings import cfg - - if cfg.soul_path.exists(): - templates.insert(0, { - "name": "SOUL.md", - "size": str(cfg.soul_path.stat().st_size), - }) - return web.json_response({"status": "ok", "templates": templates}) - - async def _get_template(self, req: web.Request) -> web.Response: - """Fetch the content of a single prompt template.""" - name = req.match_info["name"] - if ".." in name or "/" in name: - return web.json_response( - {"status": "error", "message": "invalid name"}, status=400 - ) - # Check SOUL.md first - if name == "SOUL.md": - from ...config.settings import cfg - - if cfg.soul_path.exists(): - return web.json_response({ - "status": "ok", - "name": name, - "content": cfg.soul_path.read_text(), - }) - return web.json_response( - {"status": "error", "message": "not found"}, status=404 - ) - from ...agent.prompt import _TEMPLATES_DIR - - path = _TEMPLATES_DIR / name - if not path.exists() or not path.suffix == ".md": - return web.json_response( - {"status": "error", "message": "not found"}, status=404 - ) - return web.json_response({ - "status": "ok", - "name": name, - "content": path.read_text(), - }) - async def _list_background_agents(self, _req: web.Request) -> web.Response: """Return metadata for all background agents with current policy.""" - from ...state.guardrails_config import list_background_agents + from ...state.guardrails import list_background_agents agents = list_background_agents() config = self._store.config diff --git a/app/runtime/server/routes/guardrails_routes_meta.py b/app/runtime/server/routes/guardrails_routes_meta.py new file mode 100644 index 0000000..15096ec --- /dev/null +++ b/app/runtime/server/routes/guardrails_routes_meta.py @@ -0,0 +1,135 @@ +"""Guardrails metadata handlers -- static context, template, and tool data.""" + +from __future__ import annotations + +from typing import Any + +from aiohttp import web + +BUILTIN_SDK_TOOLS: list[dict[str, str]] = [ + {"name": "create", "source": "sdk", "description": "Create a new file"}, + {"name": "edit", "source": "sdk", "description": "Edit an existing file"}, + {"name": "view", "source": "sdk", "description": "View file contents"}, + {"name": "grep", "source": "sdk", "description": "Search file contents"}, + {"name": "glob", "source": "sdk", "description": "Find files by pattern"}, + {"name": "run", "source": "sdk", "description": "Run a shell command"}, + {"name": "bash", "source": "sdk", "description": "Run a bash command"}, + {"name": "report_intent", "source": "sdk", + "description": "Log agent intent (always auto-approved)"}, +] + + +async def list_contexts_handler(_req: web.Request) -> web.Response: + """Return available execution contexts, HITL channels, and strategies.""" + return web.json_response({ + "status": "ok", + "contexts": [ + {"id": "interactive", "label": "Interactive", + "description": "User is chatting via the web UI or TUI"}, + {"id": "background", "label": "Background", + "description": "Scheduled tasks and proactive loop"}, + {"id": "voice", "label": "Voice", + "description": "Realtime voice call sessions"}, + {"id": "api", "label": "API", + "description": "External API-triggered executions"}, + ], + "channels": [ + {"id": "chat", "label": "Chat", + "description": "In-session WebSocket approval prompt"}, + {"id": "phone", "label": "Phone Call", + "description": "Outbound phone call verification via ACS"}, + ], + "strategies": [ + {"id": "allow", "label": "Allow", + "description": "Pass through without review", "color": "var(--ok)"}, + {"id": "deny", "label": "Deny", + "description": "Block immediately", "color": "var(--err)"}, + {"id": "hitl", "label": "HITL", + "description": "Human-in-the-loop approval via chat", + "color": "var(--blue)"}, + {"id": "pitl", "label": "PITL (Experimental)", + "description": "Phone-in-the-loop approval via outbound phone call" + " (experimental)", + "color": "var(--cyan, #22d3ee)"}, + {"id": "aitl", "label": "AITL", + "description": "AI-in-the-loop: background reviewer agent decides", + "color": "var(--gold)"}, + {"id": "filter", "label": "Filter", + "description": "Content Safety Prompt Shields injection detection", + "color": "var(--purple, #a78bfa)"}, + ], + }) + + +async def list_templates_handler(_req: web.Request) -> web.Response: + """Return the list of prompt template names.""" + from pathlib import Path as _Path + + from ...agent.prompt import TEMPLATES_DIR + from ...config.settings import cfg + + templates: list[dict[str, str]] = [] + if TEMPLATES_DIR.is_dir(): + for f in sorted(TEMPLATES_DIR.iterdir()): + if f.suffix == ".md": + templates.append({ + "name": f.name, + "size": str(f.stat().st_size), + }) + if cfg.soul_path.exists(): + templates.insert(0, { + "name": "SOUL.md", + "size": str(cfg.soul_path.stat().st_size), + }) + return web.json_response({"status": "ok", "templates": templates}) + + +async def get_template_handler(req: web.Request) -> web.Response: + """Fetch the content of a single prompt template.""" + name = req.match_info["name"] + if ".." in name or "/" in name: + return web.json_response( + {"status": "error", "message": "invalid name"}, status=400, + ) + if name == "SOUL.md": + from ...config.settings import cfg + + if cfg.soul_path.exists(): + return web.json_response({ + "status": "ok", + "name": name, + "content": cfg.soul_path.read_text(), + }) + return web.json_response( + {"status": "error", "message": "not found"}, status=404, + ) + from ...agent.prompt import TEMPLATES_DIR + + path = TEMPLATES_DIR / name + if not path.exists() or not path.suffix == ".md": + return web.json_response( + {"status": "error", "message": "not found"}, status=404, + ) + return web.json_response({ + "status": "ok", + "name": name, + "content": path.read_text(), + }) + + +def collect_tools() -> list[dict[str, Any]]: + """Gather custom tools defined via ``@define_tool`` plus built-in SDK tools.""" + from ...agent.tools import get_all_tools + + result: list[dict[str, Any]] = [] + for t in get_all_tools(): + name = getattr(t, "name", "") or getattr(t, "__name__", "unknown") + desc = getattr(t, "description", "") or "" + if not desc and hasattr(t, "__doc__") and t.__doc__: + first_line = t.__doc__.strip().split("\n")[0] + if not first_line.startswith("Tool("): + desc = first_line + result.append({"name": name, "source": "custom", "description": desc}) + for entry in BUILTIN_SDK_TOOLS: + result.append(dict(entry)) + return result diff --git a/app/runtime/server/routes/identity_routes.py b/app/runtime/server/routes/identity_routes.py index 4b715fb..53dd9e1 100644 --- a/app/runtime/server/routes/identity_routes.py +++ b/app/runtime/server/routes/identity_routes.py @@ -9,8 +9,8 @@ from aiohttp import web from ...config.settings import cfg -from ...services.azure import AzureCLI -from ...state.guardrails_config import GuardrailsConfigStore +from ...services.cloud.azure import AzureCLI +from ...state.guardrails import GuardrailsConfigStore from ...state.sandbox_config import SandboxConfigStore from ...util.async_helpers import run_sync @@ -151,17 +151,40 @@ async def _roles(self, _req: web.Request) -> web.Response: "condition": a.get("condition", ""), }) - # Check which required roles are present + # Resolve expected session pool scope for scope-aware checking. + session_pool_scope = self._resolve_session_pool_scope() + + # Check which required roles are present. For the Session + # Executor role we also verify that the assignment scope covers + # the configured session pool -- an assignment on a different + # resource / RG still results in 403. assigned_names = {a.get("roleDefinitionName", "") for a in assignments} checks: list[dict[str, Any]] = [] for req in _REQUIRED_ROLES: - present = req["role"] in assigned_names - checks.append({ - "feature": req["feature"], - "role": req["role"], - "present": present, - "data_action": req.get("data_action", ""), - }) + role_name = req["role"] + if role_name == "Azure ContainerApps Session Executor": + present, detail = self._check_session_executor_scope( + assignments, session_pool_scope, + ) + check: dict[str, Any] = { + "feature": req["feature"], + "role": role_name, + "present": present, + "data_action": req.get("data_action", ""), + } + if detail: + check["detail"] = detail + if session_pool_scope: + check["expected_scope"] = session_pool_scope + checks.append(check) + else: + present = role_name in assigned_names + checks.append({ + "feature": req["feature"], + "role": role_name, + "present": present, + "data_action": req.get("data_action", ""), + }) return web.json_response({ "status": "ok", @@ -250,6 +273,70 @@ async def _fix_roles(self, req: web.Request) -> web.Response: # Helpers # ------------------------------------------------------------------ + def _resolve_session_pool_scope(self) -> str: + """Return the expected ARM scope for the configured session pool, or ``""``.""" + store = self._sandbox_store or SandboxConfigStore() + pool_id = store.pool_id + if pool_id: + return pool_id + endpoint = store.session_pool_endpoint + if endpoint: + # The management-plane endpoint embeds the resource path, e.g. + # https://.dynamicsessions.io/subscriptions/.../sessionPools/ + # Extract the ARM resource id from it. + for prefix in ( + "https://", "http://", + ): + if endpoint.lower().startswith(prefix): + endpoint = endpoint[len(prefix):] + break + parts = endpoint.split("/") + try: + sub_idx = parts.index("subscriptions") + return "/" + "/".join(parts[sub_idx:]) + except ValueError: + pass + return "" + + @staticmethod + def _check_session_executor_scope( + assignments: list[Any], + expected_scope: str, + ) -> tuple[bool, str]: + """Check whether Session Executor is assigned on the right scope. + + Returns ``(present, detail)`` where *detail* explains mismatches. + """ + role_name = "Azure ContainerApps Session Executor" + matching: list[str] = [] + for a in assignments: + if not isinstance(a, dict): + continue + if a.get("roleDefinitionName", "") != role_name: + continue + scope = a.get("scope", "") + matching.append(scope) + + if not matching: + return False, "Role not assigned to this identity" + + if not expected_scope: + # No session pool configured; can't verify scope. + return True, "Role present (session pool scope not configured -- cannot verify)" + + normalised = expected_scope.lower().rstrip("/") + for scope in matching: + if scope.lower().rstrip("/") == normalised: + return True, "" + + # Role exists but on wrong scope + scopes_str = ", ".join(matching) + return False, ( + f"Role assigned on wrong scope. " + f"Expected: {expected_scope} -- " + f"Found: {scopes_str}" + ) + async def _fix_session_pool_role( self, principal_id: str, diff --git a/app/runtime/server/routes/mcp_routes.py b/app/runtime/server/routes/mcp_routes.py index 12a2e0e..46e9d94 100644 --- a/app/runtime/server/routes/mcp_routes.py +++ b/app/runtime/server/routes/mcp_routes.py @@ -167,7 +167,3 @@ async def _registry(self, req: web.Request) -> web.Response: "servers": servers, "source": "github.com/mcp", }) - - -def _error(message: str, status: int = 500) -> web.Response: - return web.json_response({"status": "error", "message": message}, status=status) diff --git a/app/runtime/server/routes/monitoring_routes.py b/app/runtime/server/routes/monitoring_routes.py index 1a645e8..eec581e 100644 --- a/app/runtime/server/routes/monitoring_routes.py +++ b/app/runtime/server/routes/monitoring_routes.py @@ -8,11 +8,12 @@ from aiohttp import web -from ...services.azure import AzureCLI +from ...services.cloud.azure import AzureCLI from ...services.otel import configure_otel, get_status, is_active, shutdown_otel from ...state.deploy_state import DeployStateStore from ...state.monitoring_config import MonitoringConfigStore from ...util.async_helpers import run_sync +from ._helpers import fail_response as _fail_response, no_az as _no_az logger = logging.getLogger(__name__) @@ -436,17 +437,3 @@ async def _create_app_insights( }) return cs - -def _no_az() -> web.Response: - return web.json_response( - {"status": "error", "message": "Azure CLI not available"}, status=500 - ) - - -def _fail_response(steps: list[dict[str, Any]]) -> web.Response: - failed = [s for s in steps if s.get("status") == "failed"] - msg = failed[0].get("detail", "Unknown error") if failed else "Unknown error" - return web.json_response( - {"status": "error", "steps": steps, "message": f"Provisioning failed: {msg}"}, - status=500, - ) diff --git a/app/runtime/server/routes/network_audit.py b/app/runtime/server/routes/network_audit.py new file mode 100644 index 0000000..d598bee --- /dev/null +++ b/app/runtime/server/routes/network_audit.py @@ -0,0 +1,299 @@ +"""Azure resource network audit helpers for the network-info API.""" + +from __future__ import annotations + +from typing import Any + +from ...services.cloud.azure import AzureCLI +from ...state.foundry_iq_config import FoundryIQConfigStore +from ...state.sandbox_config import SandboxConfigStore + +# Maps lowercased Azure resource type prefixes to audit functions. +_RESOURCE_AUDITORS: dict[str, Any] = {} # populated after function definitions + + +def collect_resource_groups( + cfg: Any, + sandbox_store: SandboxConfigStore | None, + foundry_iq_store: FoundryIQConfigStore | None, +) -> list[str]: + """Gather all known resource groups from config stores.""" + rgs: set[str] = set() + + bot_rg = cfg.env.read("RESOURCE_GROUP") or "" + if bot_rg: + rgs.add(bot_rg) + + if sandbox_store: + sb = sandbox_store.config + if sb.resource_group: + rgs.add(sb.resource_group) + + if foundry_iq_store: + fiq = foundry_iq_store.config + if fiq.resource_group: + rgs.add(fiq.resource_group) + + deploy_rg = cfg.env.read("DEPLOY_RESOURCE_GROUP") or "" + if deploy_rg: + rgs.add(deploy_rg) + + voice_rg = cfg.env.read("VOICE_RESOURCE_GROUP") or "" + if voice_rg: + rgs.add(voice_rg) + + return list(rgs) + + +def audit_resource( + az: AzureCLI, rg: str, name: str, rtype: str, +) -> dict[str, Any] | None: + """Return a network audit dict for a single Azure resource.""" + rtype_lower = rtype.lower() + for prefix, auditor in _RESOURCE_AUDITORS.items(): + if prefix in rtype_lower: + return auditor(az=az, rg=rg, name=name) + return None + + +# ------------------------------------------------------------------ +# Per-resource audit functions +# ------------------------------------------------------------------ + + +def _audit_storage( + az: AzureCLI, rg: str, name: str, +) -> dict[str, Any] | None: + info = az.json("storage", "account", "show", "--name", name, "--resource-group", rg) + if not isinstance(info, dict): + return None + props = info.get("properties") or info + net_rules = props.get("networkRuleSet") or props.get("networkAcls") or {} + default_action = net_rules.get("defaultAction") or "Allow" + ip_rules = net_rules.get("ipRules") or [] + vnet_rules = net_rules.get("virtualNetworkRules") or [] + allowed_ips = [r.get("value", r.get("ipAddressOrRange", "")) for r in ip_rules] + allowed_vnets = [r.get("id", "") for r in vnet_rules] + public_blob = props.get("allowBlobPublicAccess", True) + https_only = info.get("enableHttpsTrafficOnly", props.get("supportsHttpsTrafficOnly", True)) + min_tls = props.get("minimumTlsVersion", "TLS1_0") + private_eps = _get_private_endpoints(props) + + return { + "name": name, + "resource_group": rg, + "type": "Storage Account", + "icon": "storage", + "public_access": default_action == "Allow", + "default_action": default_action, + "allowed_ips": allowed_ips, + "allowed_vnets": allowed_vnets, + "private_endpoints": private_eps, + "https_only": https_only, + "min_tls_version": min_tls, + "extra": { + "public_blob_access": public_blob, + }, + } + + +def _audit_keyvault( + az: AzureCLI, rg: str, name: str, +) -> dict[str, Any] | None: + info = az.json("keyvault", "show", "--name", name, "--resource-group", rg) + if not isinstance(info, dict): + return None + props = info.get("properties") or info + net_acls = props.get("networkAcls") or {} + default_action = net_acls.get("defaultAction") or "Allow" + ip_rules = net_acls.get("ipRules") or [] + vnet_rules = net_acls.get("virtualNetworkRules") or [] + allowed_ips = [r.get("value", "") for r in ip_rules] + allowed_vnets = [r.get("id", "") for r in vnet_rules] + public_access = props.get("publicNetworkAccess", "Enabled") + private_eps = _get_private_endpoints(props) + rbac = props.get("enableRbacAuthorization", False) + soft_delete = props.get("enableSoftDelete", False) + purge_protect = props.get("enablePurgeProtection", False) + + return { + "name": name, + "resource_group": rg, + "type": "Key Vault", + "icon": "keyvault", + "public_access": public_access != "Disabled" and default_action == "Allow", + "default_action": default_action, + "allowed_ips": allowed_ips, + "allowed_vnets": allowed_vnets, + "private_endpoints": private_eps, + "extra": { + "public_network_access": public_access, + "rbac_authorization": rbac, + "soft_delete": soft_delete, + "purge_protection": purge_protect, + }, + } + + +def _audit_cognitive( + az: AzureCLI, rg: str, name: str, +) -> dict[str, Any] | None: + """Audit Azure OpenAI / Cognitive Services accounts.""" + info = az.json( + "cognitiveservices", "account", "show", + "--name", name, "--resource-group", rg, + ) + if not isinstance(info, dict): + return None + props = info.get("properties") or info + net_acls = props.get("networkAcls") or {} + default_action = net_acls.get("defaultAction") or "Allow" + ip_rules = net_acls.get("ipRules") or [] + vnet_rules = net_acls.get("virtualNetworkRules") or [] + allowed_ips = [r.get("value", "") for r in ip_rules] + allowed_vnets = [r.get("id", "") for r in vnet_rules] + public_access = props.get("publicNetworkAccess", "Enabled") + private_eps = _get_private_endpoints(props) + kind = info.get("kind", "CognitiveServices") + endpoint = ( + props.get("endpoint") + or (props.get("endpoints") or {}).get("OpenAI Language Model Instance API", "") + ) + + label = "Azure OpenAI" if kind.lower() == "openai" else f"Cognitive Services ({kind})" + + return { + "name": name, + "resource_group": rg, + "type": label, + "icon": "ai", + "public_access": public_access != "Disabled" and default_action == "Allow", + "default_action": default_action, + "allowed_ips": allowed_ips, + "allowed_vnets": allowed_vnets, + "private_endpoints": private_eps, + "extra": { + "public_network_access": public_access, + "kind": kind, + "endpoint": endpoint, + }, + } + + +def _audit_search( + az: AzureCLI, rg: str, name: str, +) -> dict[str, Any] | None: + """Audit Azure AI Search service.""" + info = az.json( + "search", "service", "show", + "--name", name, "--resource-group", rg, + ) + if not isinstance(info, dict): + return None + props = info.get("properties") or info + public_access = props.get("publicNetworkAccess", "enabled") + ip_rules = (props.get("networkRuleSet") or {}).get("ipRules") or [] + allowed_ips = [r.get("value", "") for r in ip_rules] + private_eps = _get_private_endpoints(props) + + return { + "name": name, + "resource_group": rg, + "type": "Azure AI Search", + "icon": "search", + "public_access": public_access.lower() != "disabled", + "default_action": "Allow" if public_access.lower() != "disabled" else "Deny", + "allowed_ips": allowed_ips, + "allowed_vnets": [], + "private_endpoints": private_eps, + "extra": { + "public_network_access": public_access, + "sku": info.get("sku", {}).get("name", ""), + }, + } + + +def _audit_acr( + az: AzureCLI, rg: str, name: str, +) -> dict[str, Any] | None: + info = az.json("acr", "show", "--name", name, "--resource-group", rg) + if not isinstance(info, dict): + return None + public_access = info.get("publicNetworkAccess", "Enabled") + net_rules = info.get("networkRuleSet") or {} + default_action = net_rules.get("defaultAction") or "Allow" + ip_rules = net_rules.get("ipRules") or [] + allowed_ips = [r.get("value", "") for r in ip_rules] + admin_enabled = info.get("adminUserEnabled", False) + + return { + "name": name, + "resource_group": rg, + "type": "Container Registry", + "icon": "acr", + "public_access": public_access == "Enabled", + "default_action": default_action, + "allowed_ips": allowed_ips, + "allowed_vnets": [], + "private_endpoints": [], + "extra": { + "admin_user_enabled": admin_enabled, + "sku": info.get("sku", {}).get("name", ""), + }, + } + + +def _audit_session_pool(rg: str, name: str, **_kw: Any) -> dict[str, Any]: + """Audit Azure Container Apps session pool.""" + return { + "name": name, + "resource_group": rg, + "type": "Session Pool", + "icon": "sandbox", + "public_access": True, + "default_action": "Allow", + "allowed_ips": [], + "allowed_vnets": [], + "private_endpoints": [], + "extra": {}, + } + + +def _audit_acs(rg: str, name: str, **_kw: Any) -> dict[str, Any]: + """Audit Azure Communication Services.""" + return { + "name": name, + "resource_group": rg, + "type": "Communication Services", + "icon": "communication", + "public_access": True, + "default_action": "Allow", + "allowed_ips": [], + "allowed_vnets": [], + "private_endpoints": [], + "extra": {}, + } + + +def _get_private_endpoints(props: dict[str, Any]) -> list[str]: + """Extract private endpoint names from a resource's properties.""" + pe_conns = props.get("privateEndpointConnections", []) + results: list[str] = [] + for pec in pe_conns: + pe = pec.get("privateEndpoint", {}) + pe_id = pe.get("id", "") + if pe_id: + results.append(pe_id.rsplit("/", 1)[-1]) + return results + + +# Populate the dispatch table now that all audit functions are defined. +_RESOURCE_AUDITORS.update({ + "microsoft.storage/storageaccounts": _audit_storage, + "microsoft.keyvault/vaults": _audit_keyvault, + "microsoft.cognitiveservices/accounts": _audit_cognitive, + "microsoft.search/searchservices": _audit_search, + "microsoft.containerregistry/registries": _audit_acr, + "microsoft.app/sessionpools": _audit_session_pool, + "microsoft.communication/communicationservices": _audit_acs, +}) diff --git a/app/runtime/server/routes/network_routes.py b/app/runtime/server/routes/network_routes.py index 8cea8df..6cfa3cf 100644 --- a/app/runtime/server/routes/network_routes.py +++ b/app/runtime/server/routes/network_routes.py @@ -10,9 +10,11 @@ from aiohttp import ClientSession, ClientTimeout, web from ...config.settings import cfg -from ...services.azure import AzureCLI +from ...services.cloud.azure import AzureCLI from ...state.foundry_iq_config import FoundryIQConfigStore from ...state.sandbox_config import SandboxConfigStore +from .network_audit import audit_resource, collect_resource_groups +from .network_topology import build_components, build_containers logger = logging.getLogger(__name__) @@ -164,10 +166,10 @@ async def _info(self, req: web.Request) -> web.Response: tunnel_info = await resolve_tunnel_info(self._tunnel, self._az) # Build component info (what network connections are configured) - components = self._build_components(deploy_mode, tunnel_info) + components = build_components(deploy_mode, self._tunnel, tunnel_info) # Build container topology for dual-container deployments - containers = self._build_containers(deploy_mode, server_mode, admin_port) + containers = build_containers(deploy_mode, server_mode, admin_port) return web.json_response({ "deploy_mode": deploy_mode, @@ -455,157 +457,6 @@ def _collect_endpoints(self, app: web.Application) -> list[dict[str, Any]]: results.sort(key=lambda e: (e["category"], e["path"], e["method"])) return results - def _build_containers( - self, - deploy_mode: str, - server_mode: str, - admin_port: int, - ) -> list[dict[str, Any]]: - """Build container topology for the network diagram. - - Only states facts that can be read from the current environment - or configuration. Identity and volume claims are intentionally - omitted -- those are verified by the probe endpoint. - """ - if deploy_mode == "docker": - runtime_port = int(os.getenv("RUNTIME_PORT", "8080")) - runtime_url = os.getenv("RUNTIME_URL", "http://runtime:8080") - # Parse actual port from RUNTIME_URL if set - if ":" in runtime_url.rsplit("/", 1)[-1]: - try: - runtime_port = int(runtime_url.rsplit(":", 1)[-1].rstrip("/")) - except ValueError: - pass - return [ - { - "role": "admin", - "label": "Admin Container", - "port": admin_port, - "host": "127.0.0.1", - "exposure": "localhost-only", - }, - { - "role": "runtime", - "label": "Agent Container", - "port": runtime_port, - "host": "runtime", - "exposure": "tunnel (Cloudflare)", - }, - ] - if deploy_mode == "aca": - aca_name = os.getenv("ACA_ENV_NAME", "polyclaw") - runtime_port = int(os.getenv("RUNTIME_PORT", "8080")) - return [ - { - "role": "admin", - "label": "Admin Container", - "port": admin_port, - "host": "internal", - "exposure": "internal-only", - }, - { - "role": "runtime", - "label": "Agent Container", - "port": runtime_port, - "host": aca_name, - "exposure": "ACA ingress", - }, - ] - # local / combined -- single process - return [ - { - "role": "combined", - "label": "Polyclaw Server", - "port": admin_port, - "host": "localhost", - "exposure": "localhost", - }, - ] - - def _build_components( - self, deploy_mode: str, tunnel_info: dict[str, Any] | None = None, - ) -> list[dict[str, Any]]: - """Build the list of network-connected components.""" - components: list[dict[str, Any]] = [] - - # Azure OpenAI / Foundry - aoai_endpoint = cfg.azure_openai_endpoint - if aoai_endpoint: - components.append({ - "name": "Azure OpenAI", - "type": "ai", - "endpoint": aoai_endpoint, - "deployment": cfg.azure_openai_realtime_deployment, - "status": "configured", - }) - - # GitHub Copilot (model backend) - if cfg.github_token: - components.append({ - "name": "GitHub Copilot", - "type": "ai", - "endpoint": "https://api.githubcopilot.com", - "model": cfg.copilot_model, - "status": "configured", - }) - - # ACS (Communication Services) - if cfg.acs_connection_string: - components.append({ - "name": "Azure Communication Services", - "type": "communication", - "status": "configured", - "source_number": cfg.acs_source_number or None, - }) - - # Cloudflare Tunnel -- use pre-resolved tunnel_info when available - if tunnel_info is not None: - components.append({ - "name": "Cloudflare Tunnel", - "type": "tunnel", - "status": "active" if tunnel_info["active"] else "inactive", - "url": tunnel_info["url"], - "restricted": tunnel_info["restricted"], - }) - else: - components.append({ - "name": "Cloudflare Tunnel", - "type": "tunnel", - "status": "active" if getattr(self._tunnel, "is_active", False) else "inactive", - "url": getattr(self._tunnel, "url", None), - "restricted": cfg.tunnel_restricted, - }) - - # Azure Bot Service - if cfg.bot_app_id: - components.append({ - "name": "Azure Bot Service", - "type": "bot", - "status": "configured", - "app_id": cfg.bot_app_id[:12] + "..." if cfg.bot_app_id else None, - }) - - # Foundry IQ / AI Search (check env for search endpoint) - search_endpoint = cfg.env.read("SEARCH_ENDPOINT") or "" - if search_endpoint: - components.append({ - "name": "Azure AI Search", - "type": "search", - "endpoint": search_endpoint, - "status": "configured", - }) - - # Storage / Data directory - components.append({ - "name": "Local Data Store", - "type": "storage", - "path": str(cfg.data_dir), - "status": "active", - "deploy_mode": deploy_mode, - }) - - return components - # ------------------------------------------------------------------ # Resource network audit # ------------------------------------------------------------------ @@ -619,7 +470,9 @@ async def _resource_audit(self, req: web.Request) -> web.Response: if not self._az: return web.json_response({"resources": [], "error": "Azure CLI not available"}) - resource_groups = self._collect_resource_groups() + resource_groups = collect_resource_groups( + cfg, self._sandbox_store, self._foundry_iq_store, + ) if not resource_groups: return web.json_response({"resources": []}) @@ -631,266 +484,8 @@ async def _resource_audit(self, req: web.Request) -> web.Response: for r in raw: rtype = (r.get("type") or "").lower() rname = r.get("name", "") - audit = self._audit_resource(rg, rname, rtype) + audit = audit_resource(self._az, rg, rname, rtype) if audit: resources.append(audit) return web.json_response({"resources": resources}) - - def _collect_resource_groups(self) -> list[str]: - """Gather all known resource groups from config stores.""" - rgs: set[str] = set() - - # Main bot / infra resource group - bot_rg = cfg.env.read("RESOURCE_GROUP") or "" - if bot_rg: - rgs.add(bot_rg) - - # Sandbox - if self._sandbox_store: - sb = self._sandbox_store.config - if sb.resource_group: - rgs.add(sb.resource_group) - - # Foundry IQ - if self._foundry_iq_store: - fiq = self._foundry_iq_store.config - if fiq.resource_group: - rgs.add(fiq.resource_group) - - # Deploy state resource group - deploy_rg = cfg.env.read("DEPLOY_RESOURCE_GROUP") or "" - if deploy_rg: - rgs.add(deploy_rg) - - # Voice resource group - voice_rg = cfg.env.read("VOICE_RESOURCE_GROUP") or "" - if voice_rg: - rgs.add(voice_rg) - - return list(rgs) - - def _audit_resource(self, rg: str, name: str, rtype: str) -> dict[str, Any] | None: - """Return a network audit dict for a single Azure resource.""" - if "microsoft.storage/storageaccounts" in rtype: - return self._audit_storage(rg, name) - if "microsoft.keyvault/vaults" in rtype: - return self._audit_keyvault(rg, name) - if "microsoft.cognitiveservices/accounts" in rtype: - return self._audit_cognitive(rg, name) - if "microsoft.search/searchservices" in rtype: - return self._audit_search(rg, name) - if "microsoft.containerregistry/registries" in rtype: - return self._audit_acr(rg, name) - if "microsoft.app/sessionpools" in rtype: - return self._audit_session_pool(rg, name) - if "microsoft.communication/communicationservices" in rtype: - return self._audit_acs(rg, name) - return None - - def _audit_storage(self, rg: str, name: str) -> dict[str, Any] | None: - info = self._az.json("storage", "account", "show", "--name", name, "--resource-group", rg) - if not isinstance(info, dict): - return None - props = info.get("properties") or info - net_rules = props.get("networkRuleSet") or props.get("networkAcls") or {} - default_action = (net_rules.get("defaultAction") or "Allow") - ip_rules = net_rules.get("ipRules") or [] - vnet_rules = net_rules.get("virtualNetworkRules") or [] - allowed_ips = [r.get("value", r.get("ipAddressOrRange", "")) for r in ip_rules] - allowed_vnets = [r.get("id", "") for r in vnet_rules] - public_blob = props.get("allowBlobPublicAccess", True) - https_only = info.get("enableHttpsTrafficOnly", props.get("supportsHttpsTrafficOnly", True)) - min_tls = props.get("minimumTlsVersion", "TLS1_0") - private_eps = self._get_private_endpoints(props) - - return { - "name": name, - "resource_group": rg, - "type": "Storage Account", - "icon": "storage", - "public_access": default_action == "Allow", - "default_action": default_action, - "allowed_ips": allowed_ips, - "allowed_vnets": allowed_vnets, - "private_endpoints": private_eps, - "https_only": https_only, - "min_tls_version": min_tls, - "extra": { - "public_blob_access": public_blob, - }, - } - - def _audit_keyvault(self, rg: str, name: str) -> dict[str, Any] | None: - info = self._az.json("keyvault", "show", "--name", name, "--resource-group", rg) - if not isinstance(info, dict): - return None - props = info.get("properties") or info - net_acls = props.get("networkAcls") or {} - default_action = (net_acls.get("defaultAction") or "Allow") - ip_rules = net_acls.get("ipRules") or [] - vnet_rules = net_acls.get("virtualNetworkRules") or [] - allowed_ips = [r.get("value", "") for r in ip_rules] - allowed_vnets = [r.get("id", "") for r in vnet_rules] - public_access = props.get("publicNetworkAccess", "Enabled") - private_eps = self._get_private_endpoints(props) - rbac = props.get("enableRbacAuthorization", False) - soft_delete = props.get("enableSoftDelete", False) - purge_protect = props.get("enablePurgeProtection", False) - - return { - "name": name, - "resource_group": rg, - "type": "Key Vault", - "icon": "keyvault", - "public_access": public_access != "Disabled" and default_action == "Allow", - "default_action": default_action, - "allowed_ips": allowed_ips, - "allowed_vnets": allowed_vnets, - "private_endpoints": private_eps, - "extra": { - "public_network_access": public_access, - "rbac_authorization": rbac, - "soft_delete": soft_delete, - "purge_protection": purge_protect, - }, - } - - def _audit_cognitive(self, rg: str, name: str) -> dict[str, Any] | None: - """Audit Azure OpenAI / Cognitive Services accounts.""" - info = self._az.json( - "cognitiveservices", "account", "show", - "--name", name, "--resource-group", rg, - ) - if not isinstance(info, dict): - return None - props = info.get("properties") or info - net_acls = props.get("networkAcls") or {} - default_action = (net_acls.get("defaultAction") or "Allow") - ip_rules = net_acls.get("ipRules") or [] - vnet_rules = net_acls.get("virtualNetworkRules") or [] - allowed_ips = [r.get("value", "") for r in ip_rules] - allowed_vnets = [r.get("id", "") for r in vnet_rules] - public_access = props.get("publicNetworkAccess", "Enabled") - private_eps = self._get_private_endpoints(props) - kind = info.get("kind", "CognitiveServices") - endpoint = props.get("endpoint") or (props.get("endpoints") or {}).get("OpenAI Language Model Instance API", "") - - label = "Azure OpenAI" if kind.lower() == "openai" else f"Cognitive Services ({kind})" - - return { - "name": name, - "resource_group": rg, - "type": label, - "icon": "ai", - "public_access": public_access != "Disabled" and default_action == "Allow", - "default_action": default_action, - "allowed_ips": allowed_ips, - "allowed_vnets": allowed_vnets, - "private_endpoints": private_eps, - "extra": { - "public_network_access": public_access, - "kind": kind, - "endpoint": endpoint, - }, - } - - def _audit_search(self, rg: str, name: str) -> dict[str, Any] | None: - """Audit Azure AI Search service.""" - info = self._az.json( - "search", "service", "show", - "--name", name, "--resource-group", rg, - ) - if not isinstance(info, dict): - return None - props = info.get("properties") or info - public_access = props.get("publicNetworkAccess", "enabled") - ip_rules = (props.get("networkRuleSet") or {}).get("ipRules") or [] - allowed_ips = [r.get("value", "") for r in ip_rules] - private_eps = self._get_private_endpoints(props) - - return { - "name": name, - "resource_group": rg, - "type": "Azure AI Search", - "icon": "search", - "public_access": public_access.lower() != "disabled", - "default_action": "Allow" if public_access.lower() != "disabled" else "Deny", - "allowed_ips": allowed_ips, - "allowed_vnets": [], - "private_endpoints": private_eps, - "extra": { - "public_network_access": public_access, - "sku": info.get("sku", {}).get("name", ""), - }, - } - - def _audit_acr(self, rg: str, name: str) -> dict[str, Any] | None: - info = self._az.json("acr", "show", "--name", name, "--resource-group", rg) - if not isinstance(info, dict): - return None - public_access = info.get("publicNetworkAccess", "Enabled") - net_rules = info.get("networkRuleSet") or {} - default_action = (net_rules.get("defaultAction") or "Allow") - ip_rules = net_rules.get("ipRules") or [] - allowed_ips = [r.get("value", "") for r in ip_rules] - admin_enabled = info.get("adminUserEnabled", False) - - return { - "name": name, - "resource_group": rg, - "type": "Container Registry", - "icon": "acr", - "public_access": public_access == "Enabled", - "default_action": default_action, - "allowed_ips": allowed_ips, - "allowed_vnets": [], - "private_endpoints": [], - "extra": { - "admin_user_enabled": admin_enabled, - "sku": info.get("sku", {}).get("name", ""), - }, - } - - def _audit_session_pool(self, rg: str, name: str) -> dict[str, Any] | None: - """Audit Azure Container Apps session pool.""" - return { - "name": name, - "resource_group": rg, - "type": "Session Pool", - "icon": "sandbox", - "public_access": True, - "default_action": "Allow", - "allowed_ips": [], - "allowed_vnets": [], - "private_endpoints": [], - "extra": {}, - } - - def _audit_acs(self, rg: str, name: str) -> dict[str, Any] | None: - """Audit Azure Communication Services.""" - return { - "name": name, - "resource_group": rg, - "type": "Communication Services", - "icon": "communication", - "public_access": True, - "default_action": "Allow", - "allowed_ips": [], - "allowed_vnets": [], - "private_endpoints": [], - "extra": {}, - } - - @staticmethod - def _get_private_endpoints(props: dict[str, Any]) -> list[str]: - """Extract private endpoint names from a resource's properties.""" - pe_conns = props.get("privateEndpointConnections", []) - results: list[str] = [] - for pec in pe_conns: - pe = pec.get("privateEndpoint", {}) - pe_id = pe.get("id", "") - if pe_id: - # Extract just the endpoint name from the full resource ID - results.append(pe_id.rsplit("/", 1)[-1]) - return results diff --git a/app/runtime/server/routes/network_topology.py b/app/runtime/server/routes/network_topology.py new file mode 100644 index 0000000..1c2157e --- /dev/null +++ b/app/runtime/server/routes/network_topology.py @@ -0,0 +1,162 @@ +"""Network topology builders for the network-info API.""" + +from __future__ import annotations + +import os +from typing import Any + +from ...config.settings import cfg + + +def build_containers( + deploy_mode: str, + server_mode: str, + admin_port: int, +) -> list[dict[str, Any]]: + """Build container topology for the network diagram. + + Only states facts that can be read from the current environment + or configuration. Identity and volume claims are intentionally + omitted -- those are verified by the probe endpoint. + """ + if deploy_mode == "docker": + runtime_port = int(os.getenv("RUNTIME_PORT", "8080")) + runtime_url = os.getenv("RUNTIME_URL", "http://runtime:8080") + # Parse actual port from RUNTIME_URL if set + if ":" in runtime_url.rsplit("/", 1)[-1]: + try: + runtime_port = int(runtime_url.rsplit(":", 1)[-1].rstrip("/")) + except ValueError: + pass + return [ + { + "role": "admin", + "label": "Admin Container", + "port": admin_port, + "host": "127.0.0.1", + "exposure": "localhost-only", + }, + { + "role": "runtime", + "label": "Agent Container", + "port": runtime_port, + "host": "runtime", + "exposure": "tunnel (Cloudflare)", + }, + ] + if deploy_mode == "aca": + aca_name = os.getenv("ACA_ENV_NAME", "polyclaw") + runtime_port = int(os.getenv("RUNTIME_PORT", "8080")) + return [ + { + "role": "admin", + "label": "Admin Container", + "port": admin_port, + "host": "internal", + "exposure": "internal-only", + }, + { + "role": "runtime", + "label": "Agent Container", + "port": runtime_port, + "host": aca_name, + "exposure": "ACA ingress", + }, + ] + # local / combined -- single process + return [ + { + "role": "combined", + "label": "Polyclaw Server", + "port": admin_port, + "host": "localhost", + "exposure": "localhost", + }, + ] + + +def build_components( + deploy_mode: str, + tunnel: object | None, + tunnel_info: dict[str, Any] | None = None, +) -> list[dict[str, Any]]: + """Build the list of network-connected components.""" + components: list[dict[str, Any]] = [] + + # Azure OpenAI / Foundry + aoai_endpoint = cfg.azure_openai_endpoint + if aoai_endpoint: + components.append({ + "name": "Azure OpenAI", + "type": "ai", + "endpoint": aoai_endpoint, + "deployment": cfg.azure_openai_realtime_deployment, + "status": "configured", + }) + + # GitHub Copilot (model backend) + if cfg.github_token: + components.append({ + "name": "GitHub Copilot", + "type": "ai", + "endpoint": "https://api.githubcopilot.com", + "model": cfg.copilot_model, + "status": "configured", + }) + + # ACS (Communication Services) + if cfg.acs_connection_string: + components.append({ + "name": "Azure Communication Services", + "type": "communication", + "status": "configured", + "source_number": cfg.acs_source_number or None, + }) + + # Cloudflare Tunnel -- use pre-resolved tunnel_info when available + if tunnel_info is not None: + components.append({ + "name": "Cloudflare Tunnel", + "type": "tunnel", + "status": "active" if tunnel_info["active"] else "inactive", + "url": tunnel_info["url"], + "restricted": tunnel_info["restricted"], + }) + else: + components.append({ + "name": "Cloudflare Tunnel", + "type": "tunnel", + "status": "active" if getattr(tunnel, "is_active", False) else "inactive", + "url": getattr(tunnel, "url", None), + "restricted": cfg.tunnel_restricted, + }) + + # Azure Bot Service + if cfg.bot_app_id: + components.append({ + "name": "Azure Bot Service", + "type": "bot", + "status": "configured", + "app_id": cfg.bot_app_id[:12] + "..." if cfg.bot_app_id else None, + }) + + # Foundry IQ / AI Search (check env for search endpoint) + search_endpoint = cfg.env.read("SEARCH_ENDPOINT") or "" + if search_endpoint: + components.append({ + "name": "Azure AI Search", + "type": "search", + "endpoint": search_endpoint, + "status": "configured", + }) + + # Storage / Data directory + components.append({ + "name": "Local Data Store", + "type": "storage", + "path": str(cfg.data_dir), + "status": "active", + "deploy_mode": deploy_mode, + }) + + return components diff --git a/app/runtime/server/routes/sandbox_routes.py b/app/runtime/server/routes/sandbox_routes.py index 3282005..65dc998 100644 --- a/app/runtime/server/routes/sandbox_routes.py +++ b/app/runtime/server/routes/sandbox_routes.py @@ -9,10 +9,11 @@ from aiohttp import web from ...sandbox import SandboxExecutor -from ...services.azure import AzureCLI +from ...services.cloud.azure import AzureCLI from ...state.deploy_state import DeployStateStore from ...state.sandbox_config import BLACKLIST, DEFAULT_WHITELIST, SandboxConfigStore from ...util.async_helpers import run_sync +from ._helpers import fail_response as _fail_response, no_az as _no_az logger = logging.getLogger(__name__) @@ -314,17 +315,3 @@ async def _create_pool( return endpoint, pool_id - -def _no_az() -> web.Response: - return web.json_response( - {"status": "error", "message": "Azure CLI not available"}, status=500 - ) - - -def _fail_response(steps: list[dict[str, Any]]) -> web.Response: - failed = [s for s in steps if s.get("status") == "failed"] - msg = failed[0].get("detail", "Unknown error") if failed else "Unknown error" - return web.json_response( - {"status": "error", "steps": steps, "message": f"Provisioning failed: {msg}"}, - status=500, - ) diff --git a/app/runtime/server/routes/security_preflight_routes.py b/app/runtime/server/routes/security_preflight_routes.py index 1b675cf..8c3c753 100644 --- a/app/runtime/server/routes/security_preflight_routes.py +++ b/app/runtime/server/routes/security_preflight_routes.py @@ -6,7 +6,7 @@ from aiohttp import web -from ...services.security_preflight import SecurityPreflightChecker +from ...services.security.security_preflight import SecurityPreflightChecker from ...util.async_helpers import run_sync logger = logging.getLogger(__name__) diff --git a/app/runtime/server/runtime_proxy.py b/app/runtime/server/runtime_proxy.py index 11ef3d2..518b412 100644 --- a/app/runtime/server/runtime_proxy.py +++ b/app/runtime/server/runtime_proxy.py @@ -65,8 +65,11 @@ async def _proxy_http( body=response_body, headers=resp_headers, ) + except (aiohttp.ClientConnectorError, aiohttp.ClientOSError, OSError): + logger.debug("[proxy.http] runtime unreachable: %s", target_url) + raise web.HTTPBadGateway(text="Runtime container unreachable") except Exception: - logger.warning("[proxy.http] runtime unreachable: %s", target_url, exc_info=True) + logger.warning("[proxy.http] runtime proxy error: %s", target_url, exc_info=True) raise web.HTTPBadGateway(text="Runtime container unreachable") diff --git a/app/runtime/server/setup/__init__.py b/app/runtime/server/setup/__init__.py new file mode 100644 index 0000000..f10a8b5 --- /dev/null +++ b/app/runtime/server/setup/__init__.py @@ -0,0 +1,19 @@ +"""Setup wizard -- Azure, deployment, voice, prerequisites, and preflight.""" + +from __future__ import annotations + +from ._routes import SetupRoutes +from .azure import AzureSetupRoutes +from .deploy import DeploymentRoutes +from .preflight import PreflightRoutes +from .prerequisites import PrerequisitesRoutes +from .voice import VoiceSetupRoutes + +__all__ = [ + "AzureSetupRoutes", + "DeploymentRoutes", + "PreflightRoutes", + "PrerequisitesRoutes", + "SetupRoutes", + "VoiceSetupRoutes", +] diff --git a/app/runtime/server/setup/_helpers.py b/app/runtime/server/setup/_helpers.py new file mode 100644 index 0000000..6a0fe99 --- /dev/null +++ b/app/runtime/server/setup/_helpers.py @@ -0,0 +1,15 @@ +"""Shared helpers for setup route handlers.""" + +from __future__ import annotations + +from aiohttp import web + + +def ok_response(message: str) -> web.Response: + """Return a standard success response.""" + return web.json_response({"status": "ok", "message": message}) + + +def error_response(message: str, status: int = 500) -> web.Response: + """Return a standard error response.""" + return web.json_response({"status": "error", "message": message}, status=status) diff --git a/app/runtime/server/setup.py b/app/runtime/server/setup/_routes.py similarity index 50% rename from app/runtime/server/setup.py rename to app/runtime/server/setup/_routes.py index 98c05ad..8565db3 100644 --- a/app/runtime/server/setup.py +++ b/app/runtime/server/setup/_routes.py @@ -9,20 +9,22 @@ import aiohttp as _aiohttp from aiohttp import web -from ..config.settings import SECRET_ENV_KEYS, ServerMode, cfg -from ..services.aca_deployer import AcaDeployer, AcaDeployRequest -from ..services.azure import AzureCLI -from ..services.deployer import BotDeployer -from ..services.github import GitHubAuth -from ..services.provisioner import Provisioner -from ..services.runtime_identity import RuntimeIdentityProvisioner -from ..state.deploy_state import DeployStateStore -from ..state.infra_config import InfraConfigStore -from ..util.async_helpers import run_sync -from .setup_preflight import PreflightRoutes -from .setup_prerequisites import PrerequisitesRoutes -from .setup_voice import VoiceSetupRoutes -from .smoke_test import SmokeTestRunner +from ...config.settings import SECRET_ENV_KEYS, ServerMode, cfg +from ...services.cloud.azure import AzureCLI +from ...services.cloud.github import GitHubAuth +from ...services.deployment.aca_deployer import AcaDeployer +from ...services.deployment.deployer import BotDeployer +from ...services.deployment.provisioner import Provisioner +from ...state.deploy_state import DeployStateStore +from ...state.infra_config import InfraConfigStore +from ...util.async_helpers import run_sync +from .azure import AzureSetupRoutes +from ._helpers import error_response as _error, ok_response as _ok +from .deploy import DeploymentRoutes +from .preflight import PreflightRoutes +from .prerequisites import PrerequisitesRoutes +from .voice import VoiceSetupRoutes +from ..smoke_test import SmokeTestRunner logger = logging.getLogger(__name__) @@ -51,20 +53,24 @@ def __init__( self._provisioner = provisioner self._deploy_store = deploy_store self._aca_deployer = aca_deployer + self._azure_routes = AzureSetupRoutes(az) self._voice_routes = VoiceSetupRoutes(az, infra_store) self._prerequisites_routes = PrerequisitesRoutes(az, infra_store, deploy_store) self._preflight_routes = PreflightRoutes(tunnel, infra_store, az=az) - self._runtime_identity = RuntimeIdentityProvisioner(az) + self._deployment_routes = DeploymentRoutes( + az=az, + provisioner=provisioner, + rebuild_adapter=rebuild_adapter, + restart_runtime=self._restart_runtime, + infra_store=infra_store, + deploy_store=deploy_store, + aca_deployer=aca_deployer, + ) def register(self, router: web.UrlDispatcher) -> None: r = router r.add_get("/api/setup/status", self.status) - r.add_post("/api/setup/azure/login", self.azure_login) - r.add_get("/api/setup/azure/check", self.azure_check) - r.add_post("/api/setup/azure/logout", self.azure_logout) - r.add_get("/api/setup/azure/subscriptions", self.list_subscriptions) - r.add_post("/api/setup/azure/subscription", self.set_subscription) - r.add_get("/api/setup/azure/resource-groups", self.list_resource_groups) + self._azure_routes.register(r) r.add_get("/api/setup/copilot/status", self.copilot_status) r.add_post("/api/setup/copilot/login", self.copilot_login) r.add_post("/api/setup/copilot/token", self.copilot_set_token) @@ -78,28 +84,17 @@ def register(self, router: web.UrlDispatcher) -> None: r.add_post("/api/setup/channels/telegram/config", self.save_telegram_config) r.add_post("/api/setup/channels/telegram/remove", self.remove_telegram_config) r.add_post("/api/setup/configuration/save", self.save_configuration) - r.add_get("/api/setup/infra/status", self.infra_status) - r.add_post("/api/setup/infra/deploy", self.infra_deploy) - r.add_post("/api/setup/infra/decommission", self.infra_decommission) self._prerequisites_routes.register(r) self._voice_routes.register(r) r.add_get("/api/setup/config", self.get_config) r.add_post("/api/setup/config", self.save_config) self._preflight_routes.register(r) - r.add_get("/api/setup/lockdown", self.lockdown_status) - r.add_post("/api/setup/lockdown", self.lockdown_toggle) - r.add_get("/api/setup/runtime-identity", self.runtime_identity_status) - r.add_post("/api/setup/runtime-identity/provision", self.runtime_identity_provision) - r.add_post("/api/setup/runtime-identity/revoke", self.runtime_identity_revoke) - r.add_get("/api/setup/aca/status", self.aca_status) - r.add_post("/api/setup/aca/deploy", self.aca_deploy) - r.add_post("/api/setup/aca/destroy", self.aca_destroy) - r.add_post("/api/setup/container/restart", self.container_restart) + self._deployment_routes.register(r) # -- Status -- async def status(self, _req: web.Request) -> web.Response: - from .tunnel_status import resolve_tunnel_info + from ..tunnel_status import resolve_tunnel_info account = self._az.account_info() copilot = self._gh.status() @@ -126,69 +121,10 @@ async def status(self, _req: web.Request) -> web.Response: "data_dir": str(cfg.data_dir), }) - # -- Azure -- - - async def azure_login(self, _req: web.Request) -> web.Response: - account = self._az.account_info() - if account: - return web.json_response({ - "status": "already_logged_in", - "user": account.get("user", {}).get("name"), - "subscription": account.get("name"), - }) - info = self._az.login_device_code() - return web.json_response({"status": "device_code_pending", **info}) - - async def azure_check(self, _req: web.Request) -> web.Response: - account = self._az.account_info() - if account: - return web.json_response({ - "status": "logged_in", - "user": account.get("user", {}).get("name"), - "subscription": account.get("name"), - }) - return web.json_response({"status": "pending"}) - - async def azure_logout(self, _req: web.Request) -> web.Response: - ok, msg = self._az.ok("logout") - self._az.invalidate_cache("account", "show") - return _ok(msg) if ok else _error(msg) - - async def list_subscriptions(self, _req: web.Request) -> web.Response: - subs = self._az.json("account", "list") or [] - return web.json_response([ - { - "id": s.get("id", ""), - "name": s.get("name", ""), - "is_default": s.get("isDefault", False), - "state": s.get("state", ""), - } - for s in (subs if isinstance(subs, list) else []) - ]) - - async def set_subscription(self, req: web.Request) -> web.Response: - body = await req.json() - sub_id = body.get("subscription_id", "").strip() - if not sub_id: - return _error("subscription_id is required", 400) - ok, msg = self._az.ok("account", "set", "--subscription", sub_id) - self._az.invalidate_cache("account", "show") - return _ok(f"Subscription set to {sub_id}") if ok else _error(f"Failed: {msg}") - - async def list_resource_groups(self, _req: web.Request) -> web.Response: - groups = self._az.json("group", "list") or [] - return web.json_response([ - {"name": g["name"], "location": g["location"]} - for g in (groups if isinstance(groups, list) else []) - ]) - # -- Copilot -- async def copilot_status(self, _req: web.Request) -> web.Response: info = self._gh.status() - # Auto-persist: if gh CLI is authenticated but no GITHUB_TOKEN in - # .env yet, extract the token and write it so the runtime container - # picks it up from the shared volume. if info.get("authenticated") and not cfg.github_token: token = self._gh.extract_token() if token: @@ -213,14 +149,7 @@ async def copilot_set_token(self, req: web.Request) -> web.Response: return _ok("GitHub token saved") async def _restart_runtime(self) -> None: - """Signal the runtime container to reload configuration. - - In two-container mode the admin container calls the runtime's - ``/api/internal/reload`` endpoint so it picks up new settings - from the shared volume without a full container restart. - - In combined mode this is a no-op (changes are already in-process). - """ + """Signal the runtime container to reload configuration.""" runtime_url = os.getenv("RUNTIME_URL", "") if not runtime_url or cfg.server_mode == ServerMode.combined: return @@ -295,11 +224,6 @@ async def toggle_tunnel_restriction(self, req: web.Request) -> web.Response: state = "enabled" if restricted else "disabled" logger.info("Tunnel restriction %s", state) - # Detect whether a container redeploy is needed for the change to - # take effect (ACA / Docker deployments where the runtime container - # reads env vars at startup). - import os - deploy_mode = "local" if os.getenv("POLYCLAW_USE_MI"): deploy_mode = "aca" @@ -426,37 +350,6 @@ async def save_configuration(self, req: web.Request) -> web.Response: "message": "Configuration saved securely", }) - # -- Infrastructure -- - - async def infra_status(self, _req: web.Request) -> web.Response: - result = await run_sync(self._provisioner.status) - return web.json_response(result) - - async def infra_deploy(self, _req: web.Request) -> web.Response: - decomm_steps = await run_sync(self._provisioner.decommission) - prov_steps = await run_sync(self._provisioner.provision) - self._rebuild() - - all_steps = decomm_steps + prov_steps - prov_failed = any(s.get("status") == "failed" for s in prov_steps) - if not prov_failed: - await self._restart_runtime() - return web.json_response({ - "status": "error" if prov_failed else "ok", - "message": "Deploy completed with errors" if prov_failed else "Deployed", - "steps": all_steps, - }, status=500 if prov_failed else 200) - - async def infra_decommission(self, _req: web.Request) -> web.Response: - steps = await run_sync(self._provisioner.decommission) - self._rebuild() - failed = any(s.get("status") == "failed" for s in steps) - return web.json_response({ - "status": "error" if failed else "ok", - "message": "Errors during decommission" if failed else "Decommissioned", - "steps": steps, - }, status=500 if failed else 200) - # -- Runtime config -- async def get_config(self, _req: web.Request) -> web.Response: @@ -483,157 +376,3 @@ async def save_config(self, req: web.Request) -> web.Response: return _error(f"Disallowed config keys: {', '.join(sorted(invalid))}", 400) cfg.write_env(**body) return _ok("Config saved") - - # -- Lock Down Mode -- - - async def lockdown_status(self, _req: web.Request) -> web.Response: - return web.json_response({ - "lockdown_mode": cfg.lockdown_mode, - "tunnel_restricted": cfg.tunnel_restricted, - }) - - async def lockdown_toggle(self, req: web.Request) -> web.Response: - body = await req.json() - enabled = bool(body.get("enabled", False)) - - if enabled: - if cfg.lockdown_mode: - return _ok("Already enabled") - cfg.write_env(LOCKDOWN_MODE="1", TUNNEL_RESTRICTED="1") - try: - self._az.ok("logout") - self._az.invalidate_cache("account", "show") - except Exception: - pass - return web.json_response({ - "status": "ok", "lockdown_mode": True, - "message": "Lock Down Mode enabled.", - }) - else: - if not cfg.lockdown_mode: - return _ok("Already disabled") - cfg.write_env(LOCKDOWN_MODE="", TUNNEL_RESTRICTED="") - return web.json_response({ - "status": "ok", "lockdown_mode": False, - "message": "Lock Down Mode disabled.", - }) - - # -- Runtime Identity -- - - async def runtime_identity_status(self, _req: web.Request) -> web.Response: - return web.json_response(self._runtime_identity.status()) - - async def runtime_identity_provision(self, req: web.Request) -> web.Response: - body = await req.json() - rg = body.get("resource_group") or cfg.env.read("BOT_RESOURCE_GROUP") - if not rg: - return _error("resource_group is required (or set BOT_RESOURCE_GROUP)", 400) - result = await run_sync(self._runtime_identity.provision, rg) - if result.get("ok"): - await self._restart_runtime() - status_code = 200 if result.get("ok") else 500 - return web.json_response(result, status=status_code) - - async def runtime_identity_revoke(self, _req: web.Request) -> web.Response: - result = await run_sync(self._runtime_identity.revoke) - return web.json_response(result) - - # -- ACA Deployment -- - - async def aca_status(self, _req: web.Request) -> web.Response: - if not self._aca_deployer: - return _error("ACA deployer not available", 500) - return web.json_response(self._aca_deployer.status()) - - async def aca_deploy(self, req: web.Request) -> web.Response: - if not self._aca_deployer: - return _error("ACA deployer not available", 500) - body = await req.json() - aca_req = AcaDeployRequest( - resource_group=body.get("resource_group", self._store.bot.resource_group), - location=body.get("location", self._store.bot.location), - bot_display_name=body.get("display_name", self._store.bot.display_name), - bot_handle=body.get("bot_handle", self._store.bot.bot_handle), - admin_port=int(body.get("admin_port", 9090)), - runtime_port=int(body.get("runtime_port", 8080)), - image_tag=body.get("image_tag", "latest"), - acr_name=body.get("acr_name", ""), - env_name=body.get("env_name", ""), - ) - result = await run_sync(self._aca_deployer.deploy, aca_req) - status_code = 200 if result.ok else 500 - return web.json_response({ - "status": "ok" if result.ok else "error", - "message": "ACA deployment complete" if result.ok else result.error, - "steps": result.steps, - "runtime_fqdn": result.runtime_fqdn, - "deploy_id": result.deploy_id, - }, status=status_code) - - async def aca_destroy(self, req: web.Request) -> web.Response: - if not self._aca_deployer: - return _error("ACA deployer not available", 500) - body = await req.json() if req.can_read_body else {} - deploy_id = body.get("deploy_id") - result = await run_sync(self._aca_deployer.destroy, deploy_id) - return web.json_response({ - "status": "ok" if result.ok else "error", - "steps": result.steps, - }) - - async def container_restart(self, _req: web.Request) -> web.Response: - """Restart the agent container (Docker or ACA) to pick up config changes.""" - import subprocess - - deploy_mode = "local" - if os.getenv("POLYCLAW_USE_MI"): - deploy_mode = "aca" - elif os.getenv("POLYCLAW_CONTAINER") == "1": - deploy_mode = "docker" - - if deploy_mode == "aca": - if not self._aca_deployer: - return _error("ACA deployer not available", 500) - result = await run_sync(self._aca_deployer.restart) - status_code = 200 if result["ok"] else 500 - return web.json_response({ - "status": "ok" if result["ok"] else "error", - "message": "ACA containers restarted" if result["ok"] else "Some containers failed to restart", - "deploy_mode": "aca", - "results": result["results"], - }, status=status_code) - - if deploy_mode == "docker": - try: - proc = subprocess.run( - ["docker", "restart", "polyclaw-runtime"], - capture_output=True, text=True, timeout=60, - ) - ok = proc.returncode == 0 - return web.json_response({ - "status": "ok" if ok else "error", - "message": "Docker runtime container restarted" if ok else proc.stderr.strip(), - "deploy_mode": "docker", - }, status=200 if ok else 500) - except Exception as exc: - logger.warning( - "[setup.container_restart] docker restart failed: %s", - exc, exc_info=True, - ) - return _error(f"Docker restart failed: {exc}") - - # Local / combined mode -- reload config in-process - await self._restart_runtime() - return web.json_response({ - "status": "ok", - "message": "Configuration reloaded", - "deploy_mode": "local", - }) - - -def _ok(message: str) -> web.Response: - return web.json_response({"status": "ok", "message": message}) - - -def _error(message: str, status: int = 500) -> web.Response: - return web.json_response({"status": "error", "message": message}, status=status) diff --git a/app/runtime/server/setup/azure.py b/app/runtime/server/setup/azure.py new file mode 100644 index 0000000..12a1e6a --- /dev/null +++ b/app/runtime/server/setup/azure.py @@ -0,0 +1,81 @@ +"""Azure authentication and subscription routes -- /api/setup/azure/*.""" + +from __future__ import annotations + +import logging + +from aiohttp import web + +from ...services.cloud.azure import AzureCLI +from ._helpers import error_response as _error, ok_response as _ok + +logger = logging.getLogger(__name__) + + +class AzureSetupRoutes: + """Handles Azure CLI login, logout, subscription listing.""" + + def __init__(self, az: AzureCLI) -> None: + self._az = az + + def register(self, router: web.UrlDispatcher) -> None: + router.add_post("/api/setup/azure/login", self.azure_login) + router.add_get("/api/setup/azure/check", self.azure_check) + router.add_post("/api/setup/azure/logout", self.azure_logout) + router.add_get("/api/setup/azure/subscriptions", self.list_subscriptions) + router.add_post("/api/setup/azure/subscription", self.set_subscription) + router.add_get("/api/setup/azure/resource-groups", self.list_resource_groups) + + async def azure_login(self, _req: web.Request) -> web.Response: + account = self._az.account_info() + if account: + return web.json_response({ + "status": "already_logged_in", + "user": account.get("user", {}).get("name"), + "subscription": account.get("name"), + }) + info = self._az.login_device_code() + return web.json_response({"status": "device_code_pending", **info}) + + async def azure_check(self, _req: web.Request) -> web.Response: + account = self._az.account_info() + if account: + return web.json_response({ + "status": "logged_in", + "user": account.get("user", {}).get("name"), + "subscription": account.get("name"), + }) + return web.json_response({"status": "pending"}) + + async def azure_logout(self, _req: web.Request) -> web.Response: + ok, msg = self._az.ok("logout") + self._az.invalidate_cache("account", "show") + return _ok(msg) if ok else _error(msg) + + async def list_subscriptions(self, _req: web.Request) -> web.Response: + subs = self._az.json("account", "list") or [] + return web.json_response([ + { + "id": s.get("id", ""), + "name": s.get("name", ""), + "is_default": s.get("isDefault", False), + "state": s.get("state", ""), + } + for s in (subs if isinstance(subs, list) else []) + ]) + + async def set_subscription(self, req: web.Request) -> web.Response: + body = await req.json() + sub_id = body.get("subscription_id", "").strip() + if not sub_id: + return _error("subscription_id is required", 400) + ok, msg = self._az.ok("account", "set", "--subscription", sub_id) + self._az.invalidate_cache("account", "show") + return _ok(f"Subscription set to {sub_id}") if ok else _error(f"Failed: {msg}") + + async def list_resource_groups(self, _req: web.Request) -> web.Response: + groups = self._az.json("group", "list") or [] + return web.json_response([ + {"name": g["name"], "location": g["location"]} + for g in (groups if isinstance(groups, list) else []) + ]) diff --git a/app/runtime/server/setup/deploy.py b/app/runtime/server/setup/deploy.py new file mode 100644 index 0000000..9b7ccb5 --- /dev/null +++ b/app/runtime/server/setup/deploy.py @@ -0,0 +1,241 @@ +"""Deployment and infrastructure routes -- /api/setup/infra/*, /api/setup/aca/*.""" + +from __future__ import annotations + +import logging +import os +import subprocess +from collections.abc import Callable, Coroutine +from typing import Any + +from aiohttp import web + +from ...config.settings import cfg +from ...services.cloud.azure import AzureCLI +from ...services.cloud.runtime_identity import RuntimeIdentityProvisioner +from ...services.deployment.aca_deployer import AcaDeployer, AcaDeployRequest +from ...services.deployment.provisioner import Provisioner +from ...state.deploy_state import DeployStateStore +from ...state.infra_config import InfraConfigStore +from ...util.async_helpers import run_sync +from ._helpers import error_response as _error, ok_response as _ok + +logger = logging.getLogger(__name__) + + +class DeploymentRoutes: + """Handles infrastructure provisioning, ACA deployment, runtime identity, lockdown.""" + + def __init__( + self, + az: AzureCLI, + provisioner: Provisioner, + rebuild_adapter: Callable, + restart_runtime: Callable[[], Coroutine[Any, Any, None]], + infra_store: InfraConfigStore, + deploy_store: DeployStateStore | None = None, + aca_deployer: AcaDeployer | None = None, + ) -> None: + self._az = az + self._provisioner = provisioner + self._rebuild = rebuild_adapter + self._restart_runtime = restart_runtime + self._store = infra_store + self._deploy_store = deploy_store + self._aca_deployer = aca_deployer + self._runtime_identity = RuntimeIdentityProvisioner(az) + + def register(self, router: web.UrlDispatcher) -> None: + router.add_get("/api/setup/infra/status", self.infra_status) + router.add_post("/api/setup/infra/deploy", self.infra_deploy) + router.add_post("/api/setup/infra/decommission", self.infra_decommission) + router.add_get("/api/setup/lockdown", self.lockdown_status) + router.add_post("/api/setup/lockdown", self.lockdown_toggle) + router.add_get("/api/setup/runtime-identity", self.runtime_identity_status) + router.add_post("/api/setup/runtime-identity/provision", self.runtime_identity_provision) + router.add_post("/api/setup/runtime-identity/revoke", self.runtime_identity_revoke) + router.add_get("/api/setup/aca/status", self.aca_status) + router.add_post("/api/setup/aca/deploy", self.aca_deploy) + router.add_post("/api/setup/aca/destroy", self.aca_destroy) + router.add_post("/api/setup/container/restart", self.container_restart) + + # -- Infrastructure -- + + async def infra_status(self, _req: web.Request) -> web.Response: + result = await run_sync(self._provisioner.status) + return web.json_response(result) + + async def infra_deploy(self, _req: web.Request) -> web.Response: + decomm_steps = await run_sync(self._provisioner.decommission) + prov_steps = await run_sync(self._provisioner.provision) + self._rebuild() + + all_steps = decomm_steps + prov_steps + prov_failed = any(s.get("status") == "failed" for s in prov_steps) + if not prov_failed: + await self._restart_runtime() + return web.json_response({ + "status": "error" if prov_failed else "ok", + "message": "Deploy completed with errors" if prov_failed else "Deployed", + "steps": all_steps, + }, status=500 if prov_failed else 200) + + async def infra_decommission(self, _req: web.Request) -> web.Response: + steps = await run_sync(self._provisioner.decommission) + self._rebuild() + failed = any(s.get("status") == "failed" for s in steps) + return web.json_response({ + "status": "error" if failed else "ok", + "message": "Errors during decommission" if failed else "Decommissioned", + "steps": steps, + }, status=500 if failed else 200) + + # -- Lock Down Mode -- + + async def lockdown_status(self, _req: web.Request) -> web.Response: + return web.json_response({ + "lockdown_mode": cfg.lockdown_mode, + "tunnel_restricted": cfg.tunnel_restricted, + }) + + async def lockdown_toggle(self, req: web.Request) -> web.Response: + body = await req.json() + enabled = bool(body.get("enabled", False)) + + if enabled: + if cfg.lockdown_mode: + return _ok("Already enabled") + cfg.write_env(LOCKDOWN_MODE="1", TUNNEL_RESTRICTED="1") + try: + self._az.ok("logout") + self._az.invalidate_cache("account", "show") + except Exception: + pass + return web.json_response({ + "status": "ok", "lockdown_mode": True, + "message": "Lock Down Mode enabled.", + }) + else: + if not cfg.lockdown_mode: + return _ok("Already disabled") + cfg.write_env(LOCKDOWN_MODE="", TUNNEL_RESTRICTED="") + return web.json_response({ + "status": "ok", "lockdown_mode": False, + "message": "Lock Down Mode disabled.", + }) + + # -- Runtime Identity -- + + async def runtime_identity_status(self, _req: web.Request) -> web.Response: + return web.json_response(self._runtime_identity.status()) + + async def runtime_identity_provision(self, req: web.Request) -> web.Response: + body = await req.json() + rg = body.get("resource_group") or cfg.env.read("BOT_RESOURCE_GROUP") + if not rg: + return _error("resource_group is required (or set BOT_RESOURCE_GROUP)", 400) + result = await run_sync(self._runtime_identity.provision, rg) + if result.get("ok"): + await self._restart_runtime() + status_code = 200 if result.get("ok") else 500 + return web.json_response(result, status=status_code) + + async def runtime_identity_revoke(self, _req: web.Request) -> web.Response: + result = await run_sync(self._runtime_identity.revoke) + return web.json_response(result) + + # -- ACA Deployment -- + + async def aca_status(self, _req: web.Request) -> web.Response: + if not self._aca_deployer: + return _error("ACA deployer not available", 500) + return web.json_response(self._aca_deployer.status()) + + async def aca_deploy(self, req: web.Request) -> web.Response: + if not self._aca_deployer: + return _error("ACA deployer not available", 500) + body = await req.json() + aca_req = AcaDeployRequest( + resource_group=body.get("resource_group", self._store.bot.resource_group), + location=body.get("location", self._store.bot.location), + bot_display_name=body.get("display_name", self._store.bot.display_name), + bot_handle=body.get("bot_handle", self._store.bot.bot_handle), + admin_port=int(body.get("admin_port", 9090)), + runtime_port=int(body.get("runtime_port", 8080)), + image_tag=body.get("image_tag", "latest"), + acr_name=body.get("acr_name", ""), + env_name=body.get("env_name", ""), + ) + result = await run_sync(self._aca_deployer.deploy, aca_req) + status_code = 200 if result.ok else 500 + return web.json_response({ + "status": "ok" if result.ok else "error", + "message": "ACA deployment complete" if result.ok else result.error, + "steps": result.steps, + "runtime_fqdn": result.runtime_fqdn, + "deploy_id": result.deploy_id, + }, status=status_code) + + async def aca_destroy(self, req: web.Request) -> web.Response: + if not self._aca_deployer: + return _error("ACA deployer not available", 500) + body = await req.json() if req.can_read_body else {} + deploy_id = body.get("deploy_id") + result = await run_sync(self._aca_deployer.destroy, deploy_id) + return web.json_response({ + "status": "ok" if result.ok else "error", + "steps": result.steps, + }) + + async def container_restart(self, _req: web.Request) -> web.Response: + """Restart the agent container (Docker or ACA) to pick up config changes.""" + deploy_mode = "local" + if os.getenv("POLYCLAW_USE_MI"): + deploy_mode = "aca" + elif os.getenv("POLYCLAW_CONTAINER") == "1": + deploy_mode = "docker" + + if deploy_mode == "aca": + if not self._aca_deployer: + return _error("ACA deployer not available", 500) + result = await run_sync(self._aca_deployer.restart) + status_code = 200 if result["ok"] else 500 + return web.json_response({ + "status": "ok" if result["ok"] else "error", + "message": ( + "ACA containers restarted" if result["ok"] + else "Some containers failed to restart" + ), + "deploy_mode": "aca", + "results": result["results"], + }, status=status_code) + + if deploy_mode == "docker": + try: + proc = subprocess.run( + ["docker", "restart", "polyclaw-runtime"], + capture_output=True, text=True, timeout=60, + ) + ok = proc.returncode == 0 + return web.json_response({ + "status": "ok" if ok else "error", + "message": ( + "Docker runtime container restarted" if ok + else proc.stderr.strip() + ), + "deploy_mode": "docker", + }, status=200 if ok else 500) + except Exception as exc: + logger.warning( + "[setup.container_restart] docker restart failed: %s", + exc, exc_info=True, + ) + return _error(f"Docker restart failed: {exc}") + + # Local / combined mode -- reload config in-process + await self._restart_runtime() + return web.json_response({ + "status": "ok", + "message": "Configuration reloaded", + "deploy_mode": "local", + }) diff --git a/app/runtime/server/setup_preflight.py b/app/runtime/server/setup/preflight.py similarity index 98% rename from app/runtime/server/setup_preflight.py rename to app/runtime/server/setup/preflight.py index d1489c5..fcd9830 100644 --- a/app/runtime/server/setup_preflight.py +++ b/app/runtime/server/setup/preflight.py @@ -8,9 +8,9 @@ import aiohttp from aiohttp import web -from ..config.settings import cfg -from ..services.azure import AzureCLI -from ..state.infra_config import InfraConfigStore +from ...config.settings import cfg +from ...services.cloud.azure import AzureCLI +from ...state.infra_config import InfraConfigStore logger = logging.getLogger(__name__) @@ -54,7 +54,7 @@ async def _preflight(self, req: web.Request) -> web.Response: jwt_ok, jwt_detail = await self._check_jwt_validation() checks.append({"check": "jwt_validation", "ok": jwt_ok, "detail": jwt_detail}) - from .tunnel_status import resolve_tunnel_info + from ..tunnel_status import resolve_tunnel_info tunnel_info = await resolve_tunnel_info(self._tunnel, self._az) tunnel_ok = tunnel_info["active"] @@ -252,7 +252,7 @@ async def _check_acs_callback_security( }) if voice_configured or voice_routes_active: - from .tunnel_status import resolve_tunnel_info + from ..tunnel_status import resolve_tunnel_info t_info = await resolve_tunnel_info(self._tunnel, self._az) tunnel_active = t_info["active"] diff --git a/app/runtime/server/setup_prerequisites.py b/app/runtime/server/setup/prerequisites.py similarity index 97% rename from app/runtime/server/setup_prerequisites.py rename to app/runtime/server/setup/prerequisites.py index 86b53e2..5b2a1fd 100644 --- a/app/runtime/server/setup_prerequisites.py +++ b/app/runtime/server/setup/prerequisites.py @@ -9,13 +9,13 @@ from aiohttp import web -from ..config.settings import SECRET_ENV_KEYS, cfg -from ..services.azure import AzureCLI -from ..services.keyvault import env_key_to_secret_name, is_kv_ref -from ..services.keyvault import kv as _kv -from ..state.deploy_state import DeployStateStore -from ..state.infra_config import InfraConfigStore -from ..util.async_helpers import run_sync +from ...config.settings import SECRET_ENV_KEYS, cfg +from ...services.cloud.azure import AzureCLI +from ...services.keyvault import env_key_to_secret_name, is_kv_ref +from ...services.keyvault import kv as _kv +from ...state.deploy_state import DeployStateStore +from ...state.infra_config import InfraConfigStore +from ...util.async_helpers import run_sync logger = logging.getLogger(__name__) diff --git a/app/runtime/server/setup/voice.py b/app/runtime/server/setup/voice.py new file mode 100644 index 0000000..8d8be63 --- /dev/null +++ b/app/runtime/server/setup/voice.py @@ -0,0 +1,470 @@ +"""Voice setup routes -- ``/api/setup/voice/*``.""" + +from __future__ import annotations + +import logging + +from aiohttp import web + +from ...config.settings import cfg +from ...services.cloud.azure import AzureCLI +from ...state.infra_config import InfraConfigStore +from ...util.async_helpers import run_sync +from .voice_provision import ( + create_acs, + create_aoai, + ensure_rbac, + ensure_rg, + persist_config, +) +from ._helpers import error_response as _error, ok_response as _ok + +logger = logging.getLogger(__name__) + + +class VoiceSetupRoutes: + """ACS + Azure OpenAI provisioning, phone config, and decommissioning.""" + + def __init__(self, az: AzureCLI, store: InfraConfigStore) -> None: + self._az = az + self._store = store + + def register(self, router: web.UrlDispatcher) -> None: + router.add_get("/api/setup/voice/config", self.get_config) + router.add_post("/api/setup/voice/deploy", self.deploy) + router.add_post("/api/setup/voice/connect", self.connect_existing) + router.add_post("/api/setup/voice/phone", self.save_phone) + router.add_post("/api/setup/voice/decommission", self.decommission) + router.add_get("/api/setup/voice/aoai/list", self.list_aoai) + router.add_get("/api/setup/voice/aoai/deployments", self.list_aoai_deployments) + router.add_post("/api/setup/voice/aoai/validate", self.validate_aoai) + router.add_get("/api/setup/voice/acs/list", self.list_acs) + router.add_get("/api/setup/voice/acs/phones", self.list_acs_phones) + + # ------------------------------------------------------------------ + # Config + # ------------------------------------------------------------------ + + async def get_config(self, _req: web.Request) -> web.Response: + vc = self._store.to_safe_dict().get("channels", {}).get("voice_call", {}) + if vc.get("acs_resource_name"): + rg = vc.get("voice_resource_group") or vc.get("resource_group") + if rg: + account = self._az.account_info() + sub_id = account.get("id", "") if account else "" + if sub_id: + vc["portal_phone_url"] = ( + f"https://portal.azure.com/#@/resource/subscriptions/{sub_id}" + f"/resourceGroups/{rg}" + f"/providers/Microsoft.Communication" + f"/CommunicationServices/{vc['acs_resource_name']}" + f"/phonenumbers" + ) + return web.json_response(vc) + + # ------------------------------------------------------------------ + # Deploy + # ------------------------------------------------------------------ + + async def deploy(self, req: web.Request) -> web.Response: + body = await req.json() + location = body.get("location", "swedencentral").strip() + voice_rg = body.get("voice_resource_group", "").strip() or "polyclaw-voice-rg" + logger.info("Voice deploy started: voice_rg=%s, location=%s", voice_rg, location) + + steps: list[dict] = [] + + if not await ensure_rg(self._az, voice_rg, location, steps): + return _voice_fail(steps) + + acs_name, conn_str = await create_acs(self._az, voice_rg, steps) + if not conn_str: + return _voice_fail(steps) + + aoai_name, aoai_endpoint, aoai_key, deployment_name = await create_aoai( + self._az, voice_rg, location, steps + ) + if not aoai_endpoint: + return _voice_fail(steps) + + if not aoai_key: + await ensure_rbac(self._az, aoai_name, voice_rg, steps) + + persist_config( + self._store, voice_rg, location, acs_name, conn_str, + aoai_name, aoai_endpoint, aoai_key, deployment_name, steps, + ) + logger.info("Voice deploy completed: acs=%s, aoai=%s", acs_name, aoai_name) + + reinit = req.app.get("_reinit_voice") + if reinit: + reinit() + + return web.json_response({ + "status": "ok", + "steps": steps, + "message": ( + "Voice infrastructure deployed." + " Now purchase a phone number in the Azure Portal." + ), + }) + + # ------------------------------------------------------------------ + # Phone + # ------------------------------------------------------------------ + + async def save_phone(self, req: web.Request) -> web.Response: + body = await req.json() + phone = body.get("phone_number", "").strip() + target = body.get("target_number", "").strip() + + updates: dict[str, str] = {} + env_updates: dict[str, str] = {} + + if phone: + if not phone.startswith("+"): + return _error("Source phone number must be in E.164 format (e.g. +14155551234)", 400) + updates["acs_source_number"] = phone + env_updates["ACS_SOURCE_NUMBER"] = phone + + if target: + if not target.startswith("+"): + return _error("Target phone number must be in E.164 format (e.g. +41781234567)", 400) + updates["voice_target_number"] = target + env_updates["VOICE_TARGET_NUMBER"] = target + + if not updates: + return _error("At least one phone number is required", 400) + + self._store.save_voice_call(**updates) + cfg.write_env(**env_updates) + + reinit = req.app.get("_reinit_voice") + if reinit: + reinit() + + return _ok("Phone number(s) saved") + + # ------------------------------------------------------------------ + # Decommission + # ------------------------------------------------------------------ + + async def decommission(self, req: web.Request) -> web.Response: + vc = self._store.channels.voice_call + voice_rg = vc.voice_resource_group or vc.resource_group + steps: list[dict] = [] + + if voice_rg: + rg_exists = await run_sync(self._az.json, "group", "show", "--name", voice_rg) + if rg_exists: + ok, msg = await run_sync( + self._az.ok, "group", "delete", "--name", voice_rg, "--yes", "--no-wait", + ) + steps.append({ + "step": "voice_rg_delete", + "status": "ok" if ok else "failed", + "name": voice_rg, + "detail": f"Deleting {voice_rg}" if ok else msg, + }) + else: + steps.append({"step": "voice_rg_delete", "status": "skip", "detail": "RG not found"}) + else: + rg = vc.resource_group + if vc.acs_resource_name and rg: + ok, _ = await run_sync( + self._az.ok, "communication", "delete", + "--name", vc.acs_resource_name, "--resource-group", rg, "--yes", + ) + steps.append({ + "step": "acs_resource", + "status": "ok" if ok else "failed", + "name": vc.acs_resource_name, + }) + + if vc.azure_openai_resource_name and rg: + ok, _ = await run_sync( + self._az.ok, "cognitiveservices", "account", "delete", + "--name", vc.azure_openai_resource_name, "--resource-group", rg, "--yes", + ) + steps.append({ + "step": "aoai_resource", + "status": "ok" if ok else "failed", + "name": vc.azure_openai_resource_name, + }) + + self._store.clear_voice_call() + cfg.write_env( + ACS_CONNECTION_STRING="", + ACS_SOURCE_NUMBER="", + VOICE_TARGET_NUMBER="", + AZURE_OPENAI_ENDPOINT="", + AZURE_OPENAI_API_KEY="", + AZURE_OPENAI_REALTIME_DEPLOYMENT="", + ACS_CALLBACK_TOKEN="", + ) + + return web.json_response({ + "status": "ok", + "steps": steps, + "message": "Voice infrastructure decommissioned", + }) + + # ------------------------------------------------------------------ + # Discovery: AOAI + # ------------------------------------------------------------------ + + async def list_aoai(self, _req: web.Request) -> web.Response: + resources = await run_sync( + self._az.json, "resource", "list", + "--resource-type", "Microsoft.CognitiveServices/accounts", + ) + if not isinstance(resources, list): + return web.json_response([]) + + return web.json_response([ + { + "name": r.get("name", ""), + "resource_group": r.get("resourceGroup", ""), + "location": r.get("location", ""), + } + for r in resources + if r.get("kind") == "OpenAI" + ]) + + async def list_aoai_deployments(self, req: web.Request) -> web.Response: + name = req.query.get("name", "").strip() + rg = req.query.get("resource_group", "").strip() + if not name or not rg: + return _error("name and resource_group are required", 400) + + deployments = await run_sync( + self._az.json, "cognitiveservices", "account", "deployment", "list", + "--name", name, "--resource-group", rg, + ) + if not isinstance(deployments, list): + return web.json_response([]) + + return web.json_response([ + { + "deployment_name": d.get("name", ""), + "model_name": d.get("properties", {}).get("model", {}).get("name", ""), + "model_version": d.get("properties", {}).get("model", {}).get("version", ""), + "model_format": d.get("properties", {}).get("model", {}).get("format", ""), + } + for d in deployments + ]) + + async def validate_aoai(self, req: web.Request) -> web.Response: + body = await req.json() + name = body.get("name", "").strip() + rg = body.get("resource_group", "").strip() + if not name or not rg: + return _error("name and resource_group are required", 400) + + deployments = await run_sync( + self._az.json, "cognitiveservices", "account", "deployment", "list", + "--name", name, "--resource-group", rg, + ) + if not isinstance(deployments, list): + return web.json_response({ + "valid": False, + "message": f"Cannot list deployments for {name}", + "deployments": [], + }) + + realtime_models = { + "gpt-4o-realtime-preview", + "gpt-realtime-mini", + "gpt-4o-mini-realtime-preview", + } + found = [] + for d in deployments: + model = d.get("properties", {}).get("model", {}) + model_name = model.get("name", "") + found.append({ + "deployment_name": d.get("name", ""), + "model_name": model_name, + "model_version": model.get("version", ""), + "is_realtime": model_name in realtime_models, + }) + + has_realtime = any(f["is_realtime"] for f in found) + return web.json_response({ + "valid": has_realtime, + "message": ( + "Realtime model deployment found" + if has_realtime + else "No realtime model deployment found. Deploy gpt-realtime-mini or gpt-4o-realtime-preview." + ), + "deployments": found, + }) + + # ------------------------------------------------------------------ + # Discovery: ACS + # ------------------------------------------------------------------ + + async def list_acs(self, _req: web.Request) -> web.Response: + resources = await run_sync(self._az.json, "communication", "list") + if not isinstance(resources, list): + return web.json_response([]) + + return web.json_response([ + { + "name": r.get("name", ""), + "resource_group": r.get("resourceGroup", ""), + "location": r.get("location", ""), + } + for r in resources + ]) + + async def list_acs_phones(self, req: web.Request) -> web.Response: + name = req.query.get("name", "").strip() + rg = req.query.get("resource_group", "").strip() + if not name or not rg: + return _error("name and resource_group are required", 400) + + keys = await run_sync( + self._az.json, "communication", "list-key", + "--name", name, "--resource-group", rg, + ) + conn_str = keys.get("primaryConnectionString", "") if isinstance(keys, dict) else "" + if not conn_str: + return web.json_response([]) + + phones = await run_sync( + self._az.json, "communication", "phonenumber", "list", + "--connection-string", conn_str, + ) + if not isinstance(phones, list): + return web.json_response([]) + + return web.json_response([ + {"phone_number": p.get("phoneNumber", "")} + for p in phones + if p.get("phoneNumber") + ]) + + # ------------------------------------------------------------------ + # Connect existing + # ------------------------------------------------------------------ + + async def connect_existing(self, req: web.Request) -> web.Response: + body = await req.json() + steps: list[dict] = [] + + aoai_name = body.get("aoai_name", "").strip() + aoai_rg = body.get("aoai_resource_group", "").strip() + aoai_deployment = body.get("aoai_deployment", "").strip() or "gpt-realtime-mini" + + if not aoai_name or not aoai_rg: + return _error("aoai_name and aoai_resource_group are required", 400) + + aoai_info = await run_sync( + self._az.json, "cognitiveservices", "account", "show", + "--name", aoai_name, "--resource-group", aoai_rg, + ) + if not isinstance(aoai_info, dict): + return _error(f"Azure OpenAI resource '{aoai_name}' not found in RG '{aoai_rg}'", 404) + + aoai_endpoint = aoai_info.get("properties", {}).get("endpoint", "") + steps.append({"step": "aoai_resource", "status": "ok", "name": f"{aoai_name} (existing)"}) + + deployments = await run_sync( + self._az.json, "cognitiveservices", "account", "deployment", "list", + "--name", aoai_name, "--resource-group", aoai_rg, + ) + dep_found = isinstance(deployments, list) and any( + d.get("name") == aoai_deployment for d in deployments + ) + if not dep_found: + steps.append({ + "step": "aoai_deployment", "status": "failed", + "name": aoai_deployment, + "detail": f"Deployment '{aoai_deployment}' not found on {aoai_name}", + }) + return _voice_fail(steps) + + steps.append({"step": "aoai_deployment", "status": "ok", "name": f"{aoai_deployment} (verified)"}) + + aoai_keys = await run_sync( + self._az.json, "cognitiveservices", "account", "keys", "list", + "--name", aoai_name, "--resource-group", aoai_rg, + ) + aoai_key = aoai_keys.get("key1", "") if isinstance(aoai_keys, dict) else "" + if aoai_key: + steps.append({"step": "aoai_keys", "status": "ok"}) + else: + logger.info("AOAI key retrieval skipped (disableLocalAuth likely true)") + steps.append({ + "step": "aoai_keys", "status": "ok", + "detail": "Key-based auth disabled; will use Entra ID (DefaultAzureCredential)", + }) + + acs_name = body.get("acs_name", "").strip() + acs_rg = body.get("acs_resource_group", "").strip() + conn_str = "" + voice_rg = aoai_rg + + if acs_name and acs_rg: + keys = await run_sync( + self._az.json, "communication", "list-key", + "--name", acs_name, "--resource-group", acs_rg, + ) + conn_str = keys.get("primaryConnectionString", "") if isinstance(keys, dict) else "" + if not conn_str: + steps.append({ + "step": "acs_resource", "status": "failed", + "name": acs_name, "detail": "Cannot retrieve connection string", + }) + return _voice_fail(steps) + steps.append({"step": "acs_resource", "status": "ok", "name": f"{acs_name} (existing)"}) + voice_rg = acs_rg + else: + voice_rg = aoai_rg + if not await ensure_rg(self._az, voice_rg, "Global", steps): + return _voice_fail(steps) + acs_name, conn_str = await create_acs(self._az, voice_rg, steps) + if not conn_str: + return _voice_fail(steps) + + location = aoai_info.get("location", "swedencentral") + + if not aoai_key: + await ensure_rbac(self._az, aoai_name, aoai_rg, steps) + + persist_config( + self._store, voice_rg, location, acs_name, conn_str, + aoai_name, aoai_endpoint, aoai_key, aoai_deployment, steps, + ) + + phone = body.get("phone_number", "").strip() + if phone: + self._store.save_voice_call(acs_source_number=phone) + cfg.write_env(ACS_SOURCE_NUMBER=phone) + steps.append({"step": "phone_number", "status": "ok", "name": phone}) + + target = body.get("target_number", "").strip() + if target: + self._store.save_voice_call(voice_target_number=target) + cfg.write_env(VOICE_TARGET_NUMBER=target) + steps.append({"step": "target_number", "status": "ok", "name": target}) + + logger.info("Voice connect completed: acs=%s, aoai=%s", acs_name, aoai_name) + + reinit = req.app.get("_reinit_voice") + if reinit: + reinit() + + return web.json_response({ + "status": "ok", + "steps": steps, + "message": "Connected to existing Azure resources.", + }) + + +def _voice_fail(steps: list[dict]) -> web.Response: + failed = [s for s in steps if s.get("status") == "failed"] + msg = failed[0].get("name", "Unknown step") if failed else "Unknown error" + return web.json_response( + {"status": "error", "steps": steps, "message": f"Voice deploy failed at: {msg}"}, + ) diff --git a/app/runtime/server/setup/voice_provision.py b/app/runtime/server/setup/voice_provision.py new file mode 100644 index 0000000..f5e1a35 --- /dev/null +++ b/app/runtime/server/setup/voice_provision.py @@ -0,0 +1,237 @@ +"""Voice infrastructure provisioning helpers. + +Standalone functions extracted from ``VoiceSetupRoutes`` for ACS + AOAI +resource creation, RBAC assignment, and configuration persistence. +""" + +from __future__ import annotations + +import functools +import logging +import secrets + +from ...config.settings import cfg +from ...services.cloud.azure import AzureCLI +from ...state.infra_config import InfraConfigStore +from ...util.async_helpers import run_sync + +logger = logging.getLogger(__name__) + + +async def ensure_rbac( + az: AzureCLI, aoai_name: str, rg: str, steps: list[dict], +) -> None: + """Assign *Cognitive Services OpenAI User* role to the current principal.""" + account = az.account_info() + if not account: + steps.append({ + "step": "rbac_assign", "status": "skip", + "detail": "Cannot determine current principal (az account show failed)", + }) + return + + principal_id = "" + principal_type = "User" + + user_info = await run_sync( + functools.partial(az.json, "ad", "signed-in-user", "show", quiet=True), + ) + if isinstance(user_info, dict) and user_info.get("id"): + principal_id = user_info["id"] + else: + sp_id = account.get("user", {}).get("name", "") + if sp_id: + sp_info = await run_sync( + functools.partial(az.json, "ad", "sp", "show", "--id", sp_id, quiet=True), + ) + if isinstance(sp_info, dict) and sp_info.get("id"): + principal_id = sp_info["id"] + principal_type = "ServicePrincipal" + + if not principal_id: + steps.append({ + "step": "rbac_assign", "status": "skip", + "detail": "Cannot determine principal ID for RBAC assignment", + }) + return + + aoai_info = await run_sync( + az.json, "cognitiveservices", "account", "show", + "--name", aoai_name, "--resource-group", rg, + ) + scope = aoai_info.get("id", "") if isinstance(aoai_info, dict) else "" + if not scope: + steps.append({ + "step": "rbac_assign", "status": "skip", + "detail": "Cannot resolve resource ID for %s" % aoai_name, + }) + return + + role = "5e0bd9bd-7b93-4f28-af87-19fc36ad61bd" + logger.info("Assigning Cognitive Services OpenAI User role: principal=%s", principal_id) + ok, msg = await run_sync( + az.ok, "role", "assignment", "create", + "--assignee-object-id", principal_id, + "--assignee-principal-type", principal_type, + "--role", role, "--scope", scope, + ) + if ok: + steps.append({"step": "rbac_assign", "status": "ok", + "detail": "Cognitive Services OpenAI User"}) + elif "already exists" in (msg or "").lower() or "conflict" in (msg or "").lower(): + steps.append({"step": "rbac_assign", "status": "ok", "detail": "Already assigned"}) + else: + steps.append({ + "step": "rbac_assign", "status": "warning", + "detail": "Role assignment failed (non-fatal): %s" % msg, + }) + logger.warning("RBAC role assignment failed (non-fatal): %s", msg) + + +async def ensure_rg( + az: AzureCLI, rg: str, location: str, steps: list[dict], +) -> bool: + """Ensure a resource group exists, creating it if necessary.""" + existing = await run_sync(az.json, "group", "show", "--name", rg) + if existing: + steps.append({"step": "resource_group", "status": "ok", + "name": "%s (existing)" % rg}) + return True + + result = await run_sync( + az.json, "group", "create", "--name", rg, "--location", location, + ) + steps.append({"step": "resource_group", + "status": "ok" if result else "failed", "name": rg}) + if not result: + logger.error("Voice deploy FAILED at resource group creation: %s", az.last_stderr) + return bool(result) + + +async def create_acs( + az: AzureCLI, rg: str, steps: list[dict], +) -> tuple[str, str]: + """Create an ACS resource and retrieve its connection string.""" + acs_name = "polyclaw-acs-%s" % secrets.token_hex(4) + acs = await run_sync( + az.json, "communication", "create", + "--name", acs_name, "--location", "Global", + "--data-location", "United States", "--resource-group", rg, + ) + steps.append({"step": "acs_resource", + "status": "ok" if acs else "failed", "name": acs_name}) + if not acs: + logger.error("Voice deploy FAILED at ACS creation: %s", az.last_stderr) + return "", "" + + keys = await run_sync( + az.json, "communication", "list-key", + "--name", acs_name, "--resource-group", rg, + ) + conn_str = keys.get("primaryConnectionString", "") if isinstance(keys, dict) else "" + steps.append({"step": "acs_keys", "status": "ok" if conn_str else "failed"}) + if not conn_str: + logger.error("Voice deploy FAILED retrieving ACS keys: %s", az.last_stderr) + return acs_name, "" + return acs_name, conn_str + + +async def create_aoai( + az: AzureCLI, rg: str, location: str, steps: list[dict], +) -> tuple[str, str, str, str]: + """Create an Azure OpenAI resource with a realtime model deployment. + + Returns ``(name, endpoint, key, deployment_name)``. + """ + aoai_name = "polyclaw-aoai-%s" % secrets.token_hex(4) + deployment_name = "gpt-realtime-mini" + + aoai = await run_sync( + az.json, "cognitiveservices", "account", "create", + "--name", aoai_name, "--resource-group", rg, + "--location", location, "--kind", "OpenAI", + "--sku", "S0", "--custom-domain", aoai_name, + ) + steps.append({"step": "aoai_resource", + "status": "ok" if aoai else "failed", "name": aoai_name}) + if not aoai: + logger.error("Voice deploy FAILED at AOAI creation: %s", az.last_stderr) + return "", "", "", "" + + dep = await run_sync( + az.json, "cognitiveservices", "account", "deployment", "create", + "--name", aoai_name, "--resource-group", rg, + "--deployment-name", deployment_name, + "--model-name", "gpt-realtime-mini", + "--model-version", "2025-10-06", + "--model-format", "OpenAI", + "--sku-capacity", "1", "--sku-name", "GlobalStandard", + ) + steps.append({"step": "aoai_deployment", + "status": "ok" if dep else "failed", "name": deployment_name}) + if not dep: + logger.error("Voice deploy FAILED at model deployment: %s", az.last_stderr) + return aoai_name, "", "", "" + + aoai_info = await run_sync( + az.json, "cognitiveservices", "account", "show", + "--name", aoai_name, "--resource-group", rg, + ) + aoai_endpoint = "" + if isinstance(aoai_info, dict): + aoai_endpoint = aoai_info.get("properties", {}).get("endpoint", "") + + aoai_keys = await run_sync( + az.json, "cognitiveservices", "account", "keys", "list", + "--name", aoai_name, "--resource-group", rg, + ) + aoai_key = aoai_keys.get("key1", "") if isinstance(aoai_keys, dict) else "" + + if not aoai_endpoint: + steps.append({"step": "aoai_keys", "status": "failed"}) + logger.error("Voice deploy FAILED retrieving AOAI endpoint") + return aoai_name, "", "", "" + + if aoai_key: + steps.append({"step": "aoai_keys", "status": "ok"}) + else: + steps.append({"step": "aoai_keys", "status": "ok", + "detail": "Using Entra ID auth"}) + + return aoai_name, aoai_endpoint, aoai_key, deployment_name + + +def persist_config( + store: InfraConfigStore, + voice_rg: str, + location: str, + acs_name: str, + conn_str: str, + aoai_name: str, + aoai_endpoint: str, + aoai_key: str, + deployment_name: str, + steps: list[dict], +) -> None: + """Write voice configuration to the infra config store and ``.env``.""" + store.save_voice_call( + acs_resource_name=acs_name, + acs_connection_string=conn_str, + azure_openai_resource_name=aoai_name, + azure_openai_endpoint=aoai_endpoint, + azure_openai_api_key=aoai_key, + azure_openai_realtime_deployment=deployment_name, + resource_group=voice_rg, + voice_resource_group=voice_rg, + location=location, + ) + callback_token = cfg.acs_callback_token + cfg.write_env( + ACS_CONNECTION_STRING=conn_str, + ACS_SOURCE_NUMBER="", + AZURE_OPENAI_ENDPOINT=aoai_endpoint, + AZURE_OPENAI_API_KEY=aoai_key, + AZURE_OPENAI_REALTIME_DEPLOYMENT=deployment_name, + ACS_CALLBACK_TOKEN=callback_token, + ) + steps.append({"step": "persist_config", "status": "ok"}) diff --git a/app/runtime/server/setup_voice.py b/app/runtime/server/setup_voice.py index ac832a7..f416a7a 100644 --- a/app/runtime/server/setup_voice.py +++ b/app/runtime/server/setup_voice.py @@ -2,16 +2,21 @@ from __future__ import annotations -import functools import logging -import secrets from aiohttp import web from ..config.settings import cfg -from ..services.azure import AzureCLI +from ..services.cloud.azure import AzureCLI from ..state.infra_config import InfraConfigStore from ..util.async_helpers import run_sync +from .voice_provision import ( + create_acs, + create_aoai, + ensure_rbac, + ensure_rg, + persist_config, +) logger = logging.getLogger(__name__) @@ -68,24 +73,24 @@ async def deploy(self, req: web.Request) -> web.Response: steps: list[dict] = [] - if not await self._ensure_rg(voice_rg, location, steps): + if not await ensure_rg(self._az, voice_rg, location, steps): return _voice_fail(steps) - acs_name, conn_str = await self._create_acs(voice_rg, steps) + acs_name, conn_str = await create_acs(self._az, voice_rg, steps) if not conn_str: return _voice_fail(steps) - aoai_name, aoai_endpoint, aoai_key, deployment_name = await self._create_aoai( - voice_rg, location, steps + aoai_name, aoai_endpoint, aoai_key, deployment_name = await create_aoai( + self._az, voice_rg, location, steps ) if not aoai_endpoint: return _voice_fail(steps) if not aoai_key: - await self._ensure_rbac(aoai_name, voice_rg, steps) + await ensure_rbac(self._az, aoai_name, voice_rg, steps) - self._persist_config( - voice_rg, location, acs_name, conn_str, + persist_config( + self._store, voice_rg, location, acs_name, conn_str, aoai_name, aoai_endpoint, aoai_key, deployment_name, steps, ) logger.info("Voice deploy completed: acs=%s, aoai=%s", acs_name, aoai_name) @@ -415,19 +420,19 @@ async def connect_existing(self, req: web.Request) -> web.Response: voice_rg = acs_rg else: voice_rg = aoai_rg - if not await self._ensure_rg(voice_rg, "Global", steps): + if not await ensure_rg(self._az, voice_rg, "Global", steps): return _voice_fail(steps) - acs_name, conn_str = await self._create_acs(voice_rg, steps) + acs_name, conn_str = await create_acs(self._az, voice_rg, steps) if not conn_str: return _voice_fail(steps) location = aoai_info.get("location", "swedencentral") if not aoai_key: - await self._ensure_rbac(aoai_name, aoai_rg, steps) + await ensure_rbac(self._az, aoai_name, aoai_rg, steps) - self._persist_config( - voice_rg, location, acs_name, conn_str, + persist_config( + self._store, voice_rg, location, acs_name, conn_str, aoai_name, aoai_endpoint, aoai_key, aoai_deployment, steps, ) @@ -455,205 +460,6 @@ async def connect_existing(self, req: web.Request) -> web.Response: "message": "Connected to existing Azure resources.", }) - # ------------------------------------------------------------------ - # Internal helpers - # ------------------------------------------------------------------ - - async def _ensure_rbac( - self, aoai_name: str, rg: str, steps: list[dict], - ) -> None: - account = self._az.account_info() - if not account: - steps.append({ - "step": "rbac_assign", "status": "skip", - "detail": "Cannot determine current principal (az account show failed)", - }) - return - - principal_id = "" - principal_type = "User" - - user_info = await run_sync( - functools.partial(self._az.json, "ad", "signed-in-user", "show", quiet=True), - ) - if isinstance(user_info, dict) and user_info.get("id"): - principal_id = user_info["id"] - else: - sp_id = account.get("user", {}).get("name", "") - if sp_id: - sp_info = await run_sync( - functools.partial(self._az.json, "ad", "sp", "show", "--id", sp_id, quiet=True), - ) - if isinstance(sp_info, dict) and sp_info.get("id"): - principal_id = sp_info["id"] - principal_type = "ServicePrincipal" - - if not principal_id: - steps.append({ - "step": "rbac_assign", "status": "skip", - "detail": "Cannot determine principal ID for RBAC assignment", - }) - return - - aoai_info = await run_sync( - self._az.json, "cognitiveservices", "account", "show", - "--name", aoai_name, "--resource-group", rg, - ) - scope = aoai_info.get("id", "") if isinstance(aoai_info, dict) else "" - if not scope: - steps.append({ - "step": "rbac_assign", "status": "skip", - "detail": f"Cannot resolve resource ID for {aoai_name}", - }) - return - - role = "5e0bd9bd-7b93-4f28-af87-19fc36ad61bd" - logger.info("Assigning Cognitive Services OpenAI User role: principal=%s", principal_id) - ok, msg = await run_sync( - self._az.ok, "role", "assignment", "create", - "--assignee-object-id", principal_id, - "--assignee-principal-type", principal_type, - "--role", role, "--scope", scope, - ) - if ok: - steps.append({"step": "rbac_assign", "status": "ok", "detail": "Cognitive Services OpenAI User"}) - elif "already exists" in (msg or "").lower() or "conflict" in (msg or "").lower(): - steps.append({"step": "rbac_assign", "status": "ok", "detail": "Already assigned"}) - else: - steps.append({ - "step": "rbac_assign", "status": "warning", - "detail": f"Role assignment failed (non-fatal): {msg}", - }) - logger.warning("RBAC role assignment failed (non-fatal): %s", msg) - - async def _ensure_rg(self, rg: str, location: str, steps: list[dict]) -> bool: - existing = await run_sync(self._az.json, "group", "show", "--name", rg) - if existing: - steps.append({"step": "resource_group", "status": "ok", "name": f"{rg} (existing)"}) - return True - - result = await run_sync( - self._az.json, "group", "create", "--name", rg, "--location", location, - ) - steps.append({"step": "resource_group", "status": "ok" if result else "failed", "name": rg}) - if not result: - logger.error("Voice deploy FAILED at resource group creation: %s", self._az.last_stderr) - return bool(result) - - async def _create_acs(self, rg: str, steps: list[dict]) -> tuple[str, str]: - acs_name = f"polyclaw-acs-{secrets.token_hex(4)}" - acs = await run_sync( - self._az.json, "communication", "create", - "--name", acs_name, "--location", "Global", - "--data-location", "United States", "--resource-group", rg, - ) - steps.append({"step": "acs_resource", "status": "ok" if acs else "failed", "name": acs_name}) - if not acs: - logger.error("Voice deploy FAILED at ACS creation: %s", self._az.last_stderr) - return "", "" - - keys = await run_sync( - self._az.json, "communication", "list-key", - "--name", acs_name, "--resource-group", rg, - ) - conn_str = keys.get("primaryConnectionString", "") if isinstance(keys, dict) else "" - steps.append({"step": "acs_keys", "status": "ok" if conn_str else "failed"}) - if not conn_str: - logger.error("Voice deploy FAILED retrieving ACS keys: %s", self._az.last_stderr) - return acs_name, "" - return acs_name, conn_str - - async def _create_aoai( - self, rg: str, location: str, steps: list[dict], - ) -> tuple[str, str, str, str]: - aoai_name = f"polyclaw-aoai-{secrets.token_hex(4)}" - deployment_name = "gpt-realtime-mini" - - aoai = await run_sync( - self._az.json, "cognitiveservices", "account", "create", - "--name", aoai_name, "--resource-group", rg, - "--location", location, "--kind", "OpenAI", - "--sku", "S0", "--custom-domain", aoai_name, - ) - steps.append({"step": "aoai_resource", "status": "ok" if aoai else "failed", "name": aoai_name}) - if not aoai: - logger.error("Voice deploy FAILED at AOAI creation: %s", self._az.last_stderr) - return "", "", "", "" - - dep = await run_sync( - self._az.json, "cognitiveservices", "account", "deployment", "create", - "--name", aoai_name, "--resource-group", rg, - "--deployment-name", deployment_name, - "--model-name", "gpt-realtime-mini", - "--model-version", "2025-10-06", - "--model-format", "OpenAI", - "--sku-capacity", "1", "--sku-name", "GlobalStandard", - ) - steps.append({"step": "aoai_deployment", "status": "ok" if dep else "failed", "name": deployment_name}) - if not dep: - logger.error("Voice deploy FAILED at model deployment: %s", self._az.last_stderr) - return aoai_name, "", "", "" - - aoai_info = await run_sync( - self._az.json, "cognitiveservices", "account", "show", - "--name", aoai_name, "--resource-group", rg, - ) - aoai_endpoint = "" - if isinstance(aoai_info, dict): - aoai_endpoint = aoai_info.get("properties", {}).get("endpoint", "") - - aoai_keys = await run_sync( - self._az.json, "cognitiveservices", "account", "keys", "list", - "--name", aoai_name, "--resource-group", rg, - ) - aoai_key = aoai_keys.get("key1", "") if isinstance(aoai_keys, dict) else "" - - if not aoai_endpoint: - steps.append({"step": "aoai_keys", "status": "failed"}) - logger.error("Voice deploy FAILED retrieving AOAI endpoint") - return aoai_name, "", "", "" - - if aoai_key: - steps.append({"step": "aoai_keys", "status": "ok"}) - else: - steps.append({"step": "aoai_keys", "status": "ok", "detail": "Using Entra ID auth"}) - - return aoai_name, aoai_endpoint, aoai_key, deployment_name - - def _persist_config( - self, - voice_rg: str, - location: str, - acs_name: str, - conn_str: str, - aoai_name: str, - aoai_endpoint: str, - aoai_key: str, - deployment_name: str, - steps: list[dict], - ) -> None: - self._store.save_voice_call( - acs_resource_name=acs_name, - acs_connection_string=conn_str, - azure_openai_resource_name=aoai_name, - azure_openai_endpoint=aoai_endpoint, - azure_openai_api_key=aoai_key, - azure_openai_realtime_deployment=deployment_name, - resource_group=voice_rg, - voice_resource_group=voice_rg, - location=location, - ) - callback_token = cfg.acs_callback_token - cfg.write_env( - ACS_CONNECTION_STRING=conn_str, - ACS_SOURCE_NUMBER="", - AZURE_OPENAI_ENDPOINT=aoai_endpoint, - AZURE_OPENAI_API_KEY=aoai_key, - AZURE_OPENAI_REALTIME_DEPLOYMENT=deployment_name, - ACS_CALLBACK_TOKEN=callback_token, - ) - steps.append({"step": "persist_config", "status": "ok"}) - def _ok(message: str) -> web.Response: return web.json_response({"status": "ok", "message": message}) diff --git a/app/runtime/server/smoke_test.py b/app/runtime/server/smoke_test.py index d39bea1..92d8fc6 100644 --- a/app/runtime/server/smoke_test.py +++ b/app/runtime/server/smoke_test.py @@ -12,7 +12,7 @@ from ..agent.agent import Agent from ..config.settings import cfg -from ..services.github import GitHubAuth +from ..services.cloud.github import GitHubAuth logger = logging.getLogger(__name__) diff --git a/app/runtime/server/tunnel_status.py b/app/runtime/server/tunnel_status.py index 156370d..35571ac 100644 --- a/app/runtime/server/tunnel_status.py +++ b/app/runtime/server/tunnel_status.py @@ -4,12 +4,14 @@ import logging import time +from dataclasses import dataclass, field from typing import Any import aiohttp -from ..services.azure import AzureCLI +from ..services.cloud.azure import AzureCLI from ..util.async_helpers import run_sync +from ..util.singletons import register_singleton logger = logging.getLogger(__name__) @@ -20,11 +22,28 @@ # Cache the probe result for 15 s (health check is fast but frequent). _PROBE_CACHE_TTL = 15.0 -_cached_endpoint: str | None = None -_cached_endpoint_ts: float = 0.0 -_cached_probe: bool = False -_cached_probe_url: str | None = None -_cached_probe_ts: float = 0.0 + +@dataclass +class _TunnelCache: + """Mutable cache for tunnel endpoint and probe results.""" + + endpoint: str | None = None + endpoint_ts: float = 0.0 + probe: bool = False + probe_url: str | None = None + probe_ts: float = 0.0 + + +_cache = _TunnelCache() + + +def _reset_tunnel_cache() -> None: + """Reset tunnel cache to default values (for test isolation).""" + global _cache # noqa: PLW0603 + _cache = _TunnelCache() + + +register_singleton(_reset_tunnel_cache) async def resolve_tunnel_info( @@ -88,33 +107,29 @@ def _endpoint_to_tunnel_url(endpoint: str) -> str: async def _get_bot_endpoint_cached(az: AzureCLI) -> str | None: """Return the bot messaging endpoint, cached for ``_ENDPOINT_CACHE_TTL`` s.""" - global _cached_endpoint, _cached_endpoint_ts # noqa: PLW0603 - now = time.monotonic() - if _cached_endpoint is not None and (now - _cached_endpoint_ts) < _ENDPOINT_CACHE_TTL: - return _cached_endpoint + if _cache.endpoint is not None and (now - _cache.endpoint_ts) < _ENDPOINT_CACHE_TTL: + return _cache.endpoint endpoint = await run_sync(az.get_bot_endpoint) - _cached_endpoint = endpoint - _cached_endpoint_ts = now + _cache.endpoint = endpoint + _cache.endpoint_ts = now return endpoint async def _probe_tunnel_cached(url: str) -> bool: """Probe the tunnel with a short TTL cache to avoid hammering.""" - global _cached_probe, _cached_probe_url, _cached_probe_ts # noqa: PLW0603 - now = time.monotonic() if ( - _cached_probe_url == url - and (now - _cached_probe_ts) < _PROBE_CACHE_TTL + _cache.probe_url == url + and (now - _cache.probe_ts) < _PROBE_CACHE_TTL ): - return _cached_probe + return _cache.probe active = await _probe_tunnel(url) - _cached_probe = active - _cached_probe_url = url - _cached_probe_ts = now + _cache.probe = active + _cache.probe_url = url + _cache.probe_ts = now return active diff --git a/app/runtime/server/wiring.py b/app/runtime/server/wiring.py new file mode 100644 index 0000000..94d63d3 --- /dev/null +++ b/app/runtime/server/wiring.py @@ -0,0 +1,265 @@ +"""Service wiring -- initialises core components, stores, and external services.""" + +from __future__ import annotations + +import logging +from typing import Any + +from ..config.settings import ServerMode, cfg + +logger = logging.getLogger(__name__) + + +def create_adapter() -> object: + """Create a BotFrameworkAdapter with the current cfg credentials.""" + from botbuilder.core import BotFrameworkAdapter, BotFrameworkAdapterSettings, TurnContext + from botbuilder.schema import Activity, ActivityTypes + + settings = BotFrameworkAdapterSettings( + app_id=cfg.bot_app_id or None, + app_password=cfg.bot_app_password or None, + channel_auth_tenant=cfg.bot_app_tenant_id or None, + ) + adapter = BotFrameworkAdapter(settings) + + async def on_error(context: TurnContext, error: Exception) -> None: + logger.error("Bot turn error: %s", error, exc_info=True) + try: + activity = Activity(type=ActivityTypes.message, text="An error occurred.") + if (context.activity.channel_id or "").lower() == "telegram": + activity.text_format = "plain" + await context.send_activity(activity) + except Exception: + pass + + adapter.on_turn_error = on_error + return adapter + + +def _append_token(url: str, token: str) -> str: + sep = "&" if "?" in url else "?" + return f"{url}{sep}token={token}" + + +def create_voice_handler(agent: object, tunnel: object | None = None) -> object | None: + """Instantiate the ACS + Realtime voice handler, or ``None`` if not configured.""" + cfg.reload() + if not (cfg.acs_connection_string and cfg.acs_source_number and cfg.azure_openai_endpoint): + logger.info("Voice call not configured (ACS/AOAI settings missing)") + return None + + from azure.core.credentials import AzureKeyCredential as _AKC + + from ..realtime import AcsCaller, RealtimeMiddleTier, RealtimeRoutes + + def _resolve_acs_urls() -> tuple[str, str]: + token = cfg.acs_callback_token + cb_path = cfg.acs_callback_path + ws_path = cfg.acs_media_streaming_websocket_path + + logger.debug( + "_resolve_acs_urls: cb_path=%r, ws_path=%r, token=%s", + cb_path, ws_path, "set" if token else "empty", + ) + + # If both paths are already absolute URLs, use them directly + cb_is_absolute = cb_path.startswith("https://") + ws_is_absolute = ws_path.startswith("wss://") + if cb_is_absolute and ws_is_absolute: + resolved = _append_token(cb_path, token), _append_token(ws_path, token) + logger.info("ACS URLs (absolute): callback=%s, ws=%s", resolved[0], resolved[1]) + return resolved + + # Otherwise, resolve relative paths against the tunnel URL + tunnel_url = (getattr(tunnel, "url", None) or "").rstrip("/") + if tunnel_url: + cb = cb_path if cb_is_absolute else f"{tunnel_url}{cb_path or '/api/voice/acs-callback'}" + ws = ws_path if ws_is_absolute else ( + tunnel_url.replace("https://", "wss://").replace("http://", "ws://") + + (ws_path or "/api/voice/media-streaming") + ) + resolved = _append_token(cb, token), _append_token(ws, token) + logger.info("ACS URLs (tunnel): callback=%s, ws=%s", resolved[0], resolved[1]) + return resolved + logger.warning("ACS URLs fallback to localhost -- calls will fail") + return ( + cb_path or f"http://localhost:{cfg.admin_port}/api/voice/acs-callback", + ws_path or f"ws://localhost:{cfg.admin_port}/api/voice/media-streaming", + ) + + caller = AcsCaller( + source_number=cfg.acs_source_number, + acs_connection_string=cfg.acs_connection_string, + resolve_urls=_resolve_acs_urls, + resolve_source_number=lambda: cfg.acs_source_number, + ) + + realtime_credential: _AKC | object + if cfg.azure_openai_api_key: + realtime_credential = _AKC(cfg.azure_openai_api_key) + else: + from azure.identity import DefaultAzureCredential as _DAC + + realtime_credential = _DAC() + + rt_middleware = RealtimeMiddleTier( + endpoint=cfg.azure_openai_endpoint, + deployment=cfg.azure_openai_realtime_deployment, + credential=realtime_credential, + agent=agent, + ) + handler = RealtimeRoutes( + caller, + rt_middleware, + callback_token=cfg.acs_callback_token, + acs_resource_id=cfg.acs_resource_id, + ) + logger.info("Voice call (ACS + Realtime) enabled: source=%s", cfg.acs_source_number) + return handler + + +async def init_core(mode: ServerMode) -> dict[str, Any]: + """Initialise the agent, adapter, bot, and session store. + + Returns a dict of component references keyed by name. + """ + result: dict[str, Any] = { + "agent": None, + "adapter": None, + "conv_store": None, + "session_store": None, + "bot": None, + "bot_ep": None, + } + is_runtime = mode in (ServerMode.runtime, ServerMode.combined) + is_admin = mode in (ServerMode.admin, ServerMode.combined) + + if is_runtime: + from ..agent.agent import Agent + from ..messaging.bot import Bot + from ..messaging.proactive import ConversationReferenceStore + from ..state.session_store import SessionStore + from .bot_endpoint import BotEndpoint + + logger.info("[init_core] creating Agent ...") + agent = Agent() + logger.info("[init_core] starting Agent (Copilot CLI) ...") + await agent.start() + logger.info("[init_core] Agent started successfully") + + adapter = create_adapter() + conv_store = ConversationReferenceStore() + session_store = SessionStore() + + hitl = agent.hitl_interceptor + bot = Bot(agent, conv_store, hitl=hitl) + bot.session_store = session_store + bot.adapter = adapter + bot_ep = BotEndpoint(adapter, bot) + logger.info("[init_core] core initialization complete") + + result.update( + agent=agent, adapter=adapter, conv_store=conv_store, + session_store=session_store, bot=bot, bot_ep=bot_ep, + ) + + if is_admin and not is_runtime: + from ..state.session_store import SessionStore + + result["session_store"] = SessionStore() + logger.info("[init_core] admin-only initialization complete") + + return result + + +def init_services(mode: ServerMode) -> dict[str, Any]: + """Initialise state stores, cloud services, and background processors. + + Returns a dict of service/store references keyed by name. + """ + from ..state.deploy_state import DeployStateStore + from ..state.foundry_iq_config import FoundryIQConfigStore + from ..state.guardrails import GuardrailsConfigStore + from ..state.infra_config import InfraConfigStore + from ..state.mcp_config import McpConfigStore + from ..state.monitoring_config import MonitoringConfigStore + from ..state.sandbox_config import SandboxConfigStore + + is_admin = mode in (ServerMode.admin, ServerMode.combined) + is_runtime = mode in (ServerMode.runtime, ServerMode.combined) + + result: dict[str, Any] = { + "tunnel": None, + "deploy_store": DeployStateStore(), + "infra_store": InfraConfigStore(), + "mcp_store": McpConfigStore(), + "sandbox_store": SandboxConfigStore(), + "foundry_iq_store": FoundryIQConfigStore(), + "guardrails_store": GuardrailsConfigStore(), + "monitoring_store": MonitoringConfigStore(), + "az": None, + "gh": None, + "deployer": None, + "provisioner": None, + "aca_deployer": None, + "scheduler": None, + "proactive_store": None, + "sandbox_executor": None, + } + + if is_runtime: + from ..services.tunnel import CloudflareTunnel + + result["tunnel"] = CloudflareTunnel() + + # Admin-side services + if is_admin: + from ..services.cloud.azure import AzureCLI + from ..services.cloud.github import GitHubAuth + from ..services.deployment.aca_deployer import AcaDeployer + from ..services.deployment.deployer import BotDeployer + from ..services.deployment.provisioner import Provisioner + + az = AzureCLI() + deployer = BotDeployer(az, result["deploy_store"]) + result.update( + az=az, + gh=GitHubAuth(), + deployer=deployer, + provisioner=Provisioner( + az, deployer, + result["infra_store"], result["deploy_store"], + tunnel=result["tunnel"], + ), + aca_deployer=AcaDeployer(az, result["deploy_store"]), + ) + elif is_runtime: + from ..services.cloud.azure import AzureCLI + from ..services.deployment.deployer import BotDeployer + from ..services.deployment.provisioner import Provisioner + + az = AzureCLI() + deployer = BotDeployer(az, result["deploy_store"]) + result.update( + az=az, + deployer=deployer, + provisioner=Provisioner( + az, deployer, + result["infra_store"], result["deploy_store"], + tunnel=result["tunnel"], + ), + ) + + # Runtime-side services + if is_runtime: + from ..sandbox import SandboxExecutor + from ..scheduler import get_scheduler + from ..state.proactive import get_proactive_store + + result.update( + scheduler=get_scheduler(), + proactive_store=get_proactive_store(), + sandbox_executor=SandboxExecutor(result["sandbox_store"]), + ) + + return result diff --git a/app/runtime/services/__init__.py b/app/runtime/services/__init__.py index 6a959ae..6d54069 100644 --- a/app/runtime/services/__init__.py +++ b/app/runtime/services/__init__.py @@ -1,12 +1,38 @@ -"""External service integrations.""" - -__all__ = [ - "AzureCLI", - "BotDeployer", - "CloudflareTunnel", - "GitHubAuth", - "KeyVaultClient", - "MisconfigChecker", - "Provisioner", - "ResourceTracker", -] +"""External service integrations. + +Re-exports are lazy to avoid a circular import: the ``Settings`` singleton +imports ``services.keyvault`` during init, and the cloud / deployment / +security sub-packages import ``cfg`` at module level. Deferring those +imports via ``__getattr__`` means only ``keyvault`` is loaded while +``Settings()`` runs; the heavier sub-packages load on first access, by +which time ``cfg`` is assigned. +""" + +from __future__ import annotations + +_LAZY_IMPORTS: dict[str, tuple[str, str]] = { + "AzureCLI": (".cloud", "AzureCLI"), + "GitHubAuth": (".cloud", "GitHubAuth"), + "AcaDeployer": (".deployment", "AcaDeployer"), + "BotDeployer": (".deployment", "BotDeployer"), + "Provisioner": (".deployment", "Provisioner"), + "MisconfigChecker": (".security", "MisconfigChecker"), + "PromptShieldService": (".security", "PromptShieldService"), + "SecurityPreflightChecker": (".security", "SecurityPreflightChecker"), + "CloudflareTunnel": (".tunnel", "CloudflareTunnel"), +} + + +def __getattr__(name: str) -> object: + if name in _LAZY_IMPORTS: + import importlib + + subpkg, attr = _LAZY_IMPORTS[name] + mod = importlib.import_module(subpkg, __name__) + val = getattr(mod, attr) + globals()[name] = val # cache for subsequent access + return val + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__all__ = list(_LAZY_IMPORTS) diff --git a/app/runtime/services/aca_deployer.py b/app/runtime/services/aca_deployer.py deleted file mode 100644 index 6238775..0000000 --- a/app/runtime/services/aca_deployer.py +++ /dev/null @@ -1,843 +0,0 @@ -"""Azure Container Apps deployer.""" - -from __future__ import annotations - -import logging -import os -import secrets -import subprocess -import time -from dataclasses import dataclass, field -from typing import Any - -from ..config.settings import cfg -from ..state.deploy_state import DeploymentRecord, DeployStateStore -from ..state.sandbox_config import SandboxConfigStore -from .azure import AzureCLI - -logger = logging.getLogger(__name__) - -_IMAGE_NAME = "polyclaw" -_LOCAL_IMAGE = "polyclaw:latest" -_MI_NAME = "polyclaw-runtime-mi" -_ENV_NAME_PREFIX = "polyclaw-env" -_BOT_CONTRIBUTOR_ROLE = "Azure Bot Service Contributor Role" -_RG_READER_ROLE = "Reader" -_SESSION_EXECUTOR_ROLE = "Azure ContainerApps Session Executor" - - -@dataclass -class AcaDeployRequest: - - resource_group: str = "polyclaw-rg" - location: str = "eastus" - bot_display_name: str = "polyclaw" - bot_handle: str = "" - admin_port: int = 9090 - runtime_port: int = 8080 - image_tag: str = "latest" - acr_name: str = "" - env_name: str = "" - - -@dataclass -class AcaDeployResult: - - ok: bool = False - steps: list[dict[str, Any]] = field(default_factory=list) - error: str = "" - runtime_fqdn: str = "" - acr_name: str = "" - deploy_id: str = "" - - -class AcaDeployer: - - def __init__(self, az: AzureCLI, deploy_store: DeployStateStore | None = None) -> None: - self._az = az - self._deploy_store = deploy_store - - def deploy(self, req: AcaDeployRequest) -> AcaDeployResult: - steps: list[dict[str, Any]] = [] - result = AcaDeployResult(steps=steps) - - logger.info("[aca] Starting ACA deployment: rg=%s, location=%s", req.resource_group, req.location) - - rec = DeploymentRecord.new(kind="aca") - result.deploy_id = rec.deploy_id - if self._deploy_store: - self._deploy_store.register(rec) - - try: - self._cleanup_stale_resources(req, steps) - - if not self._ensure_resource_group(req, steps, rec): - result.error = "Resource group creation failed" - return result - - env_vars = self._load_env_vars(steps) - - acr_name = self._ensure_acr(req, steps, rec) - if not acr_name: - result.error = "Container registry creation failed" - return result - result.acr_name = acr_name - - if not self._push_image(acr_name, req.image_tag, steps): - result.error = "Image push failed" - return result - - acr_user, acr_pass = self._get_acr_credentials(acr_name) - if not acr_user: - result.error = "Could not retrieve ACR admin credentials" - return result - - mi_id, mi_client_id = self._ensure_managed_identity(req, steps, rec) - if not mi_id: - result.error = "Managed identity creation failed" - return result - - self._assign_rbac(mi_client_id, req.resource_group, steps) - - env_name, env_id = self._ensure_aca_environment(req, steps, rec) - if not env_name: - result.error = "Container Apps environment creation failed" - return result - - runtime_fqdn = self._ensure_runtime_app( - req, env_id, acr_name, mi_id, mi_client_id, - acr_user, acr_pass, env_vars, steps, rec, - ) - if not runtime_fqdn: - result.error = "Runtime container app creation failed" - return result - result.runtime_fqdn = runtime_fqdn - - ip_steps = self._configure_ip_whitelist(req, steps) - steps.extend(ip_steps) - - runtime_url = f"https://{runtime_fqdn}" - cfg.write_env( - ACA_RUNTIME_FQDN=runtime_fqdn, - ACA_ACR_NAME=acr_name, - ACA_ENV_NAME=env_name, - ACA_MI_RESOURCE_ID=mi_id, - ACA_MI_CLIENT_ID=mi_client_id, - RUNTIME_URL=runtime_url, - ) - os.environ["RUNTIME_URL"] = runtime_url - logger.info("[aca] RUNTIME_URL set to %s", runtime_url) - steps.append({"step": "write_aca_config", "status": "ok"}) - - result.ok = True - logger.info("[aca] Deployment complete: runtime=%s", runtime_fqdn) - - except Exception as exc: - logger.error("[aca] Deployment failed: %s", exc, exc_info=True) - result.error = str(exc) - steps.append({"step": "unexpected_error", "status": "failed", "detail": str(exc)}) - - if self._deploy_store and rec: - if result.ok: - rec.config = { - "runtime_fqdn": result.runtime_fqdn, - "acr_name": result.acr_name, - } - else: - rec.mark_stopped() - self._deploy_store.update(rec) - - return result - - def destroy(self, deploy_id: str | None = None) -> AcaDeployResult: - steps: list[dict[str, Any]] = [] - result = AcaDeployResult(steps=steps) - - rec = None - if deploy_id and self._deploy_store: - rec = self._deploy_store.get(deploy_id) - elif self._deploy_store: - rec = self._deploy_store.current_aca() - - rg = ( - cfg.env.read("BOT_RESOURCE_GROUP") - or (rec.resource_groups[0] if rec and rec.resource_groups else "") - ) - - if rg: - cleaned = self._delete_aca_resources(rg, steps, step_label="destroy") - if cleaned: - logger.info("[aca] Destroyed %d resource(s): %s", - len(cleaned), ", ".join(cleaned)) - else: - logger.info("[aca] No ACA resources found to destroy in %s", rg) - - cfg.write_env( - ACA_RUNTIME_FQDN="", - ACA_ACR_NAME="", ACA_ENV_NAME="", - ACA_MI_RESOURCE_ID="", - ACA_MI_CLIENT_ID="", - RUNTIME_URL="", - ) - steps.append({"step": "clear_aca_config", "status": "ok"}) - - if rec and self._deploy_store: - rec.mark_destroyed() - self._deploy_store.update(rec) - - result.ok = True - return result - - def status(self) -> dict[str, Any]: - runtime_fqdn = cfg.env.read("ACA_RUNTIME_FQDN") - return { - "deployed": bool(runtime_fqdn), - "runtime_fqdn": runtime_fqdn or None, - "acr_name": cfg.env.read("ACA_ACR_NAME") or None, - "env_name": cfg.env.read("ACA_ENV_NAME") or None, - "mi_client_id": cfg.env.read("ACA_MI_CLIENT_ID") or None, - } - - def restart(self) -> dict[str, Any]: - rg = cfg.env.read("BOT_RESOURCE_GROUP") or "polyclaw-rg" - app_name = "polyclaw-runtime" - - revisions = self._az.json( - "containerapp", "revision", "list", - "--name", app_name, - "--resource-group", rg, - quiet=True, - ) - if not revisions or not isinstance(revisions, list): - ok, msg = self._az.ok( - "containerapp", "update", - "--name", app_name, - "--resource-group", rg, - "--set-env-vars", f"RESTART_TS={int(time.time())}", - ) - result_detail = { - "app": app_name, - "status": "ok" if ok else "failed", - "method": "update", - "detail": msg if not ok else "forced new revision", - } - logger.info("[aca.restart] result=%r", result_detail) - return {"ok": ok, "results": [result_detail]} - - active = next( - (r["name"] for r in revisions if r.get("properties", {}).get("active")), - revisions[0].get("name") if revisions else None, - ) - if not active: - result_detail = { - "app": app_name, "status": "failed", - "method": "revision_restart", - "detail": "no active revision found", - } - logger.info("[aca.restart] result=%r", result_detail) - return {"ok": False, "results": [result_detail]} - - ok, msg = self._az.ok( - "containerapp", "revision", "restart", - "--name", app_name, - "--resource-group", rg, - "--revision", active, - ) - result_detail = { - "app": app_name, - "status": "ok" if ok else "failed", - "method": "revision_restart", - "detail": active if ok else msg, - } - logger.info("[aca.restart] result=%r", result_detail) - return {"ok": ok, "results": [result_detail]} - - def _delete_aca_resources( - self, rg: str, steps: list[dict], *, step_label: str = "cleanup", - ) -> list[str]: - rg_exists = self._az.json("group", "show", "--name", rg, quiet=True) - if not isinstance(rg_exists, dict): - logger.info("[aca] Resource group %s does not exist -- nothing to clean", rg) - return [] - - cleaned: list[str] = [] - - apps = self._az.json( - "containerapp", "list", - "--resource-group", rg, quiet=True, - ) - for app in (apps if isinstance(apps, list) else []): - name = app.get("name", "") - if not name: - continue - logger.info("[aca] Deleting container app: %s (waiting)", name) - ok, _ = self._az.ok( - "containerapp", "delete", "--name", name, - "--resource-group", rg, "--yes", - ) - if ok: - cleaned.append(f"containerapp/{name}") - steps.append({"step": f"{step_label}/containerapp/{name}", - "status": "ok" if ok else "failed"}) - - identities = self._az.json( - "identity", "list", - "--resource-group", rg, quiet=True, - ) - for mi in (identities if isinstance(identities, list) else []): - name = mi.get("name", "") - if not name: - continue - logger.info("[aca] Deleting managed identity: %s (waiting)", name) - ok, _ = self._az.ok( - "identity", "delete", "--name", name, - "--resource-group", rg, - ) - if ok: - cleaned.append(f"identity/{name}") - steps.append({"step": f"{step_label}/identity/{name}", - "status": "ok" if ok else "failed"}) - - envs = self._az.json( - "containerapp", "env", "list", - "--resource-group", rg, quiet=True, - ) - for env in (envs if isinstance(envs, list) else []): - name = env.get("name", "") - if not name: - continue - logger.info("[aca] Deleting ACA environment: %s (no-wait)", name) - ok, _ = self._az.ok( - "containerapp", "env", "delete", "--name", name, - "--resource-group", rg, "--yes", "--no-wait", - ) - if ok: - cleaned.append(f"aca-env/{name}") - steps.append({"step": f"{step_label}/aca-env/{name}", - "status": "ok" if ok else "failed"}) - - acrs = self._az.json( - "acr", "list", - "--resource-group", rg, quiet=True, - ) - for acr in (acrs if isinstance(acrs, list) else []): - name = acr.get("name", "") - if not name: - continue - logger.info("[aca] Deleting ACR: %s", name) - ok, _ = self._az.ok( - "acr", "delete", "--name", name, - "--resource-group", rg, "--yes", - ) - if ok: - cleaned.append(f"acr/{name}") - steps.append({"step": f"{step_label}/acr/{name}", - "status": "ok" if ok else "failed"}) - - workspaces = self._az.json( - "monitor", "log-analytics", "workspace", "list", - "--resource-group", rg, quiet=True, - ) - for ws in (workspaces if isinstance(workspaces, list) else []): - name = ws.get("name", "") - if not name: - continue - logger.info("[aca] Deleting Log Analytics workspace: %s", name) - ok, _ = self._az.ok( - "monitor", "log-analytics", "workspace", "delete", - "--workspace-name", name, - "--resource-group", rg, "--yes", "--force", - ) - if ok: - cleaned.append(f"log-analytics/{name}") - steps.append({"step": f"{step_label}/log-analytics/{name}", - "status": "ok" if ok else "failed"}) - - storage_accounts = self._az.json( - "storage", "account", "list", - "--resource-group", rg, quiet=True, - ) - for sa in (storage_accounts if isinstance(storage_accounts, list) else []): - name = sa.get("name", "") - if not name: - continue - tags = sa.get("tags", {}) or {} - kind = sa.get("kind", "") - if "polyclaw_deploy" in tags or kind == "StorageV2": - logger.info("[aca] Deleting storage account: %s", name) - ok, _ = self._az.ok( - "storage", "account", "delete", "--name", name, - "--resource-group", rg, "--yes", - ) - if ok: - cleaned.append(f"storage/{name}") - steps.append({"step": f"{step_label}/storage/{name}", - "status": "ok" if ok else "failed"}) - - return cleaned - - def _cleanup_stale_resources( - self, req: AcaDeployRequest, steps: list[dict], - ) -> None: - logger.info("[aca] Pre-flight: cleaning all ACA resources in %s ...", req.resource_group) - cleaned = self._delete_aca_resources(req.resource_group, steps, step_label="cleanup") - if cleaned: - detail = ", ".join(cleaned) - logger.info("[aca] Cleaned %d resource(s): %s", len(cleaned), detail) - else: - logger.info("[aca] No resources to clean") - steps.append({"step": "cleanup", "status": "ok", "detail": "nothing to clean"}) - - def _ensure_resource_group( - self, req: AcaDeployRequest, steps: list[dict], rec: DeploymentRecord, - ) -> bool: - logger.info("[aca] Step 1/10: Ensuring resource group %s ...", req.resource_group) - tag_args = ["--tags", f"polyclaw_deploy={rec.tag}"] - result = self._az.json( - "group", "create", "--name", req.resource_group, - "--location", req.location, *tag_args, - ) - if result: - steps.append({"step": "resource_group", "status": "ok", "detail": req.resource_group}) - if req.resource_group not in rec.resource_groups: - rec.resource_groups.append(req.resource_group) - return True - steps.append({"step": "resource_group", "status": "failed", "detail": self._az.last_stderr}) - return False - - def _load_env_vars(self, steps: list[dict]) -> dict[str, str]: - from .keyvault import is_kv_ref, kv - - env_map = cfg.env.read_all() - _DEPLOYER_KEYS = frozenset({ - "ACA_RUNTIME_FQDN", "ACA_ACR_NAME", "ACA_ENV_NAME", - "ACA_STORAGE_ACCOUNT", "ACA_MI_RESOURCE_ID", "ACA_MI_CLIENT_ID", - "RUNTIME_URL", - }) - filtered = {k: v for k, v in env_map.items() if k not in _DEPLOYER_KEYS and v} - - resolved_count = 0 - for key, value in list(filtered.items()): - if is_kv_ref(value): - try: - plaintext = kv.resolve_value(value) - if plaintext: - filtered[key] = plaintext - resolved_count += 1 - logger.info("[aca] Resolved @kv: ref for %s", key) - else: - logger.warning( - "[aca] @kv: ref for %s resolved to empty -- removing", key, - ) - del filtered[key] - except Exception: - logger.error( - "[aca] Failed to resolve @kv: ref for %s -- removing", - key, exc_info=True, - ) - del filtered[key] - - count = len(filtered) - logger.info( - "[aca] Step 2/10: Loaded %d env var(s) from local .env " - "(%d @kv: references resolved)", - count, resolved_count, - ) - steps.append({"step": "load_env_vars", "status": "ok", - "detail": f"{count} variable(s), {resolved_count} @kv: resolved"}) - return filtered - - def _ensure_acr( - self, req: AcaDeployRequest, steps: list[dict], rec: DeploymentRecord, - ) -> str: - logger.info("[aca] Step 3/10: Creating container registry ...") - acr_name = "polyclaw" + secrets.token_hex(4) - acr_name = acr_name[:50].replace("-", "") - - result = self._az.json( - "acr", "create", - "--resource-group", req.resource_group, - "--name", acr_name, - "--sku", "Basic", - "--admin-enabled", "true", - "--location", req.location, - ) - if not result: - steps.append({ - "step": "acr_create", "status": "failed", - "detail": self._az.last_stderr, - }) - return "" - steps.append({"step": "acr_create", "status": "ok", "detail": acr_name}) - rec.add_resource("acr", req.resource_group, acr_name, "Container registry") - return acr_name - - def _get_acr_credentials(self, acr_name: str) -> tuple[str, str]: - creds = self._az.json("acr", "credential", "show", "--name", acr_name) - if not isinstance(creds, dict): - return "", "" - username = creds.get("username", "") - passwords = creds.get("passwords", []) - password = passwords[0].get("value", "") if passwords else "" - return username, password - - def _push_image( - self, acr_name: str, tag: str, steps: list[dict], - ) -> bool: - logger.info("[aca] Step 4/10: Pushing pre-built image to ACR ...") - local_image = f"{_IMAGE_NAME}:{tag}" - remote_image = f"{acr_name}.azurecr.io/{_IMAGE_NAME}:{tag}" - - check = subprocess.run( - ["docker", "image", "inspect", local_image], - capture_output=True, text=True, - ) - if check.returncode != 0: - detail = ( - f"Local image '{local_image}' not found. " - "Build it first with: docker build --platform linux/amd64 " - f"-t {local_image} ." - ) - logger.error("[aca] %s", detail) - steps.append({"step": "image_push", "status": "failed", "detail": detail}) - return False - - logger.info("[aca] Logging in to ACR %s ...", acr_name) - ok, msg = self._az.ok("acr", "login", "--name", acr_name) - if not ok: - detail = f"ACR login failed: {msg or self._az.last_stderr}" - logger.error("[aca] %s", detail) - steps.append({"step": "image_push", "status": "failed", "detail": detail}) - return False - - logger.info("[aca] Tagging %s -> %s", local_image, remote_image) - tag_result = subprocess.run( - ["docker", "tag", local_image, remote_image], - capture_output=True, text=True, - ) - if tag_result.returncode != 0: - detail = f"docker tag failed: {tag_result.stderr.strip()}" - logger.error("[aca] %s", detail) - steps.append({"step": "image_push", "status": "failed", "detail": detail}) - return False - - logger.info("[aca] Pushing %s (this may take 1-2 minutes) ...", remote_image) - push_result = subprocess.run( - ["docker", "push", remote_image], - capture_output=True, text=True, timeout=600, - ) - if push_result.returncode != 0: - detail = f"docker push failed: {push_result.stderr.strip()[:500]}" - logger.error("[aca] %s", detail) - steps.append({"step": "image_push", "status": "failed", "detail": detail}) - return False - - logger.info("[aca] Image pushed: %s", remote_image) - steps.append({"step": "image_push", "status": "ok", "detail": remote_image}) - return True - - def _ensure_managed_identity( - self, req: AcaDeployRequest, steps: list[dict], rec: DeploymentRecord, - ) -> tuple[str, str]: - logger.info("[aca] Step 5/10: Creating managed identity ...") - result = self._az.json( - "identity", "create", - "--name", _MI_NAME, - "--resource-group", req.resource_group, - "--location", req.location, - ) - if not isinstance(result, dict): - steps.append({"step": "managed_identity", "status": "failed", - "detail": self._az.last_stderr}) - return "", "" - - mi_id = result.get("id", "") - client_id = result.get("clientId", "") - steps.append({"step": "managed_identity", "status": "ok", "detail": _MI_NAME}) - rec.add_resource("managed_identity", req.resource_group, _MI_NAME, - "Runtime scoped identity") - return mi_id, client_id - - def _assign_rbac( - self, - mi_principal_id: str, - resource_group: str, - steps: list[dict], - ) -> None: - logger.info("[aca] Step 6/10: Assigning RBAC ...") - account = self._az.account_info() - sub_id = account.get("id", "") if account else "" - rg_scope = f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" - - for role in (_BOT_CONTRIBUTOR_ROLE, _RG_READER_ROLE): - label = role.lower().replace(" ", "_") - assigned = False - for attempt in range(4): - if attempt: - delay = 10 * attempt - logger.info( - "[aca] RBAC retry %d/3 for %s in %ds ...", - attempt, label, delay, - ) - time.sleep(delay) - ok, _msg = self._az.ok( - "role", "assignment", "create", - "--assignee", mi_principal_id, - "--role", role, - "--scope", rg_scope, - ) - if ok or "already exists" in (self._az.last_stderr or "").lower(): - assigned = True - break - if assigned: - steps.append({"step": f"rbac_{label}", "status": "ok", - "detail": f"{role} on {resource_group}"}) - else: - steps.append({"step": f"rbac_{label}", "status": "failed", - "detail": self._az.last_stderr}) - - session_scope = self._session_pool_scope(sub_id) - if session_scope: - label = _SESSION_EXECUTOR_ROLE.lower().replace(" ", "_") - assigned = False - for attempt in range(4): - if attempt: - delay = 10 * attempt - logger.info( - "[aca] RBAC retry %d/3 for %s in %ds ...", - attempt, label, delay, - ) - time.sleep(delay) - ok, _msg = self._az.ok( - "role", "assignment", "create", - "--assignee", mi_principal_id, - "--role", _SESSION_EXECUTOR_ROLE, - "--scope", session_scope, - ) - if ok or "already exists" in (self._az.last_stderr or "").lower(): - assigned = True - break - if assigned: - steps.append({"step": f"rbac_{label}", "status": "ok", - "detail": f"{_SESSION_EXECUTOR_ROLE} on session pool"}) - else: - steps.append({"step": f"rbac_{label}", "status": "failed", - "detail": self._az.last_stderr}) - - def _session_pool_scope(self, subscription_id: str) -> str | None: - try: - store = SandboxConfigStore() - pool_id = store.pool_id - if pool_id: - return pool_id - rg = store.resource_group - name = store.pool_name - if rg and name: - return ( - f"/subscriptions/{subscription_id}/resourceGroups/{rg}" - f"/providers/Microsoft.App/sessionPools/{name}" - ) - except Exception as exc: - logger.debug("Could not resolve session pool scope: %s", exc) - return None - - def _ensure_aca_environment( - self, - req: AcaDeployRequest, - steps: list[dict], - rec: DeploymentRecord, - ) -> tuple[str, str]: - logger.info("[aca] Step 7/10: Creating ACA environment ...") - env_name = f"{_ENV_NAME_PREFIX}-{secrets.token_hex(4)}" - - result = self._az.json( - "containerapp", "env", "create", - "--name", env_name, - "--resource-group", req.resource_group, - "--location", req.location, - ) - if not isinstance(result, dict): - steps.append({ - "step": "aca_environment", "status": "failed", - "detail": self._az.last_stderr, - }) - return "", "" - - env_id = result.get("id", "") - steps.append({"step": "aca_environment", "status": "ok", "detail": env_name}) - rec.add_resource("aca_environment", req.resource_group, env_name, - "Container Apps environment") - return env_name, env_id - - def _ensure_runtime_app( - self, - req: AcaDeployRequest, - env_id: str, - acr_name: str, - mi_id: str, - mi_client_id: str, - acr_user: str, - acr_pass: str, - env_vars: dict[str, str], - steps: list[dict], - rec: DeploymentRecord, - ) -> str: - app_name = "polyclaw-runtime" - admin_secret = cfg.admin_secret or secrets.token_urlsafe(24) - image = f"{acr_name}.azurecr.io/{_IMAGE_NAME}:{req.image_tag}" - - logger.info("[aca] Step 8/10: Creating runtime container app ...") - - _SECRET_ENV_KEYS = frozenset({ - "RUNTIME_SP_PASSWORD", "ACS_CALLBACK_TOKEN", - "GITHUB_TOKEN", "BOT_APP_PASSWORD", - "ACS_CONNECTION_STRING", "AZURE_OPENAI_API_KEY", - }) - _SKIP = frozenset({ - "POLYCLAW_MODE", "POLYCLAW_DATA_DIR", "ADMIN_PORT", - "ADMIN_SECRET", "POLYCLAW_CONTAINER", "POLYCLAW_USE_MI", - "AZURE_CLIENT_ID", - }) | _SECRET_ENV_KEYS - aca_secrets: dict[str, str] = { - "admin-secret": admin_secret, - } - for env_key in _SECRET_ENV_KEYS: - secret_name = env_key.lower().replace("_", "-") - value = env_vars.get(env_key, "") - if value: - aca_secrets[secret_name] = value - - env_pairs = [ - "POLYCLAW_MODE=runtime", - f"ADMIN_PORT={req.runtime_port}", - "ADMIN_SECRET=secretref:admin-secret", - "POLYCLAW_CONTAINER=1", - "POLYCLAW_USE_MI=1", - f"AZURE_CLIENT_ID={mi_client_id}", - ] - for env_key in sorted(_SECRET_ENV_KEYS): - secret_name = env_key.lower().replace("_", "-") - if secret_name in aca_secrets: - env_pairs.append(f"{env_key}=secretref:{secret_name}") - - for key, value in sorted(env_vars.items()): - if key not in _SKIP and value: - env_pairs.append(f"{key}={value}") - - logger.info("[aca] Container env vars: %d total (%d via ACA secrets)", - len(env_pairs), len(aca_secrets)) - - secret_pairs = [f"{name}={value}" for name, value in sorted(aca_secrets.items())] - - create_args: list[str] = [ - "containerapp", "create", - "--name", app_name, - "--resource-group", req.resource_group, - "--environment", env_id, - "--image", image, - "--cpu", "2", "--memory", "4Gi", - "--min-replicas", "1", "--max-replicas", "1", - "--ingress", "external", - "--target-port", str(req.runtime_port), - "--registry-server", f"{acr_name}.azurecr.io", - "--registry-username", acr_user, - "--registry-password", acr_pass, - "--secrets", *secret_pairs, - "--env-vars", *env_pairs, - ] - - result = self._az.json(*create_args) - if not isinstance(result, dict): - detail = self._az.last_stderr - logger.error("[aca] containerapp create failed: %s", detail[:1000]) - steps.append({ - "step": "runtime_container_app", "status": "failed", - "detail": detail[:500], - }) - return "" - - logger.info("[aca] Assigning managed identity to container app ...") - id_ok, id_msg = self._az.ok( - "containerapp", "identity", "assign", - "--name", app_name, - "--resource-group", req.resource_group, - "--user-assigned", mi_id, - ) - if not id_ok: - logger.warning("[aca] MI assignment failed (non-fatal): %s", id_msg) - - fqdn = result.get("properties", {}).get("configuration", {}).get( - "ingress", {} - ).get("fqdn", "") - - if fqdn: - bot_endpoint = f"https://{fqdn}/api/messages" - self._az.ok( - "containerapp", "update", - "--name", app_name, - "--resource-group", req.resource_group, - "--set-env-vars", f"BOT_ENDPOINT={bot_endpoint}", - ) - - steps.append({"step": "runtime_container_app", "status": "ok", "detail": fqdn}) - rec.add_resource("container_app", req.resource_group, app_name, - "Runtime data plane (MI-scoped)") - return fqdn - - def _configure_ip_whitelist( - self, - req: AcaDeployRequest, - steps: list[dict], - ) -> list[dict[str, Any]]: - ip_steps: list[dict[str, Any]] = [] - - public_ip = self._detect_public_ip() - if not public_ip: - ip_steps.append({ - "step": "ip_whitelist", - "status": "skipped", - "detail": "Could not detect public IP -- runtime ingress unrestricted", - }) - return ip_steps - - ok, msg = self._az.ok( - "containerapp", "ingress", "access-restriction", "set", - "--name", "polyclaw-runtime", - "--resource-group", req.resource_group, - "--rule-name", "allow-deployer", - "--ip-address", f"{public_ip}/32", - "--action", "Allow", - "--description", "Allow deployer IP", - ) - if ok: - ip_steps.append({ - "step": "ip_whitelist", - "status": "ok", - "detail": f"Runtime restricted to {public_ip}/32", - }) - else: - ip_steps.append({ - "step": "ip_whitelist", - "status": "warning", - "detail": f"Could not set IP restriction: {msg}", - }) - - return ip_steps - - @staticmethod - def _detect_public_ip() -> str: - import urllib.request - - for url in ( - "https://api.ipify.org", - "https://ifconfig.me/ip", - "https://checkip.amazonaws.com", - ): - try: - with urllib.request.urlopen(url, timeout=10) as resp: - ip = resp.read().decode().strip() - if ip and "." in ip: - return ip - except Exception: - continue - return "" diff --git a/app/runtime/services/cloud/__init__.py b/app/runtime/services/cloud/__init__.py new file mode 100644 index 0000000..ae853a4 --- /dev/null +++ b/app/runtime/services/cloud/__init__.py @@ -0,0 +1,9 @@ +"""Cloud identity and CLI integrations.""" + +from __future__ import annotations + +from .azure import AzureCLI +from .github import GitHubAuth +from .runtime_identity import RuntimeIdentityProvisioner + +__all__ = ["AzureCLI", "GitHubAuth", "RuntimeIdentityProvisioner"] diff --git a/app/runtime/services/cloud/_azure_rbac.py b/app/runtime/services/cloud/_azure_rbac.py new file mode 100644 index 0000000..b89cb06 --- /dev/null +++ b/app/runtime/services/cloud/_azure_rbac.py @@ -0,0 +1,43 @@ +"""Shared Azure RBAC constants and helpers used across deployment and identity modules.""" + +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + +# Common identity / image names. +MI_NAME = "polyclaw-runtime-mi" +IMAGE_NAME = "polyclaw" + +# RBAC role names used for runtime identity scoping. +BOT_CONTRIBUTOR_ROLE = "Azure Bot Service Contributor Role" +RG_READER_ROLE = "Reader" +KV_SECRETS_ROLE = "Key Vault Secrets Officer" +SESSION_EXECUTOR_ROLE = "Azure ContainerApps Session Executor" + + +def session_pool_scope(subscription_id: str) -> str | None: + """Return the ARM resource scope for the ACA session pool, or ``None``. + + The session pool id is stored in ``sandbox.json`` after provisioning. + Shared between ``runtime_identity`` and ``aca_provision``. + """ + from ...state.sandbox_config import SandboxConfigStore + + try: + store = SandboxConfigStore() + pool_id = store.pool_id + if pool_id: + return pool_id + rg = store.resource_group + name = store.pool_name + if rg and name: + return ( + f"/subscriptions/{subscription_id}/resourceGroups/{rg}" + f"/providers/Microsoft.App/sessionPools/{name}" + ) + except Exception as exc: + logger.debug("Could not resolve session pool scope: %s", exc) + return None diff --git a/app/runtime/services/azure.py b/app/runtime/services/cloud/azure.py similarity index 98% rename from app/runtime/services/azure.py rename to app/runtime/services/cloud/azure.py index 2e0946e..6fde317 100644 --- a/app/runtime/services/azure.py +++ b/app/runtime/services/cloud/azure.py @@ -13,8 +13,7 @@ from time import time as _time from typing import Any -from ..config.settings import cfg -from ..util.result import Result +from ...util.result import Result logger = logging.getLogger(__name__) @@ -164,6 +163,8 @@ def login_device_code(self) -> dict[str, Any]: def get_bot_endpoint(self) -> str | None: """Read the messaging endpoint URL from the deployed Azure Bot Service.""" + from ...config.settings import cfg + rg = cfg.env.read("BOT_RESOURCE_GROUP") name = cfg.env.read("BOT_NAME") if not (rg and name): @@ -175,6 +176,8 @@ def get_bot_endpoint(self) -> str | None: return endpoint or None def update_endpoint(self, endpoint: str) -> Result: + from ...config.settings import cfg + rg = cfg.env.read("BOT_RESOURCE_GROUP") name = cfg.env.read("BOT_NAME") if not (rg and name): @@ -198,6 +201,8 @@ def update_endpoint(self, endpoint: str) -> Result: return Result.ok("Endpoint updated") def get_channels(self) -> dict[str, bool]: + from ...config.settings import cfg + rg = cfg.env.read("BOT_RESOURCE_GROUP") name = cfg.env.read("BOT_NAME") if not (rg and name): @@ -277,6 +282,8 @@ def configure_telegram(self, token: str, *, validated_name: str = "") -> Result: return Result.fail(f"Invalid Telegram token: {tok_result.message}") display = tok_result.message logger.info("Telegram token validated: %s", display) + from ...config.settings import cfg + rg = cfg.env.read("BOT_RESOURCE_GROUP") name = cfg.env.read("BOT_NAME") if not (rg and name): @@ -289,6 +296,8 @@ def configure_telegram(self, token: str, *, validated_name: str = "") -> Result: return Result.ok(f"Telegram configured ({display})") if result else result def remove_channel(self, channel: str) -> Result: + from ...config.settings import cfg + rg = cfg.env.read("BOT_RESOURCE_GROUP") name = cfg.env.read("BOT_NAME") if not (rg and name): diff --git a/app/runtime/services/github.py b/app/runtime/services/cloud/github.py similarity index 97% rename from app/runtime/services/github.py rename to app/runtime/services/cloud/github.py index 76e9d49..cf55537 100644 --- a/app/runtime/services/github.py +++ b/app/runtime/services/cloud/github.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import os import re import selectors @@ -9,7 +10,7 @@ from time import time as _time from typing import Any -from ..config.settings import cfg +logger = logging.getLogger(__name__) class GitHubAuth: @@ -19,6 +20,8 @@ def __init__(self) -> None: self._login_proc: subprocess.Popen | None = None def status(self) -> dict[str, Any]: + from ...config.settings import cfg + if cfg.github_token: return {"authenticated": True, "details": "Using GITHUB_TOKEN from environment"} try: diff --git a/app/runtime/services/runtime_identity.py b/app/runtime/services/cloud/runtime_identity.py similarity index 90% rename from app/runtime/services/runtime_identity.py rename to app/runtime/services/cloud/runtime_identity.py index 183e2ee..62d6493 100644 --- a/app/runtime/services/runtime_identity.py +++ b/app/runtime/services/cloud/runtime_identity.py @@ -22,28 +22,20 @@ import logging from typing import Any -from ..config.settings import cfg -from ..state.sandbox_config import SandboxConfigStore +from ...state.sandbox_config import SandboxConfigStore +from ._azure_rbac import ( + BOT_CONTRIBUTOR_ROLE as _BOT_CONTRIBUTOR_ROLE, + KV_SECRETS_ROLE as _KV_SECRETS_ROLE, + MI_NAME as _MI_NAME, + RG_READER_ROLE as _RG_READER_ROLE, + SESSION_EXECUTOR_ROLE as _SESSION_EXECUTOR_ROLE, + session_pool_scope as _session_pool_scope_fn, +) from .azure import AzureCLI logger = logging.getLogger(__name__) _SP_DISPLAY_NAME = "polyclaw-runtime" -_MI_NAME = "polyclaw-runtime-mi" -_BOT_CONTRIBUTOR_ROLE = "Azure Bot Service Contributor Role" - -# Additional role so the SP can create / update resource groups tagged for -# the runtime (the RG itself must already exist or the admin must create it -# before handing off). -_RG_READER_ROLE = "Reader" - -# The runtime needs to read and write Key Vault secrets (e.g. MicrosoftAppId, -# MicrosoftAppPassword) that are required for bot channel registration. -_KV_SECRETS_ROLE = "Key Vault Secrets Officer" - -# Required for interacting with ACA Dynamic Sessions (code interpreter / -# shell sandbox). Must be scoped to the session pool resource. -_SESSION_EXECUTOR_ROLE = "Azure ContainerApps Session Executor" class RuntimeIdentityProvisioner: @@ -147,6 +139,8 @@ def provision(self, resource_group: str) -> dict[str, Any]: self._assign_role(app_id, _SESSION_EXECUTOR_ROLE, session_scope, steps) # 7. Write the SP credentials to the shared .env + from ...config.settings import cfg + cfg.write_env( RUNTIME_SP_APP_ID=app_id, RUNTIME_SP_PASSWORD=password, @@ -170,6 +164,8 @@ def provision(self, resource_group: str) -> dict[str, Any]: def revoke(self) -> dict[str, Any]: """Delete the runtime SP and clear env vars.""" + from ...config.settings import cfg + steps: list[dict[str, str]] = [] app_id = cfg.env.read("RUNTIME_SP_APP_ID") @@ -253,6 +249,8 @@ def provision_managed_identity( self._assign_role(principal_id, _SESSION_EXECUTOR_ROLE, session_scope, steps) # Write MI config to .env so the ACA deployer can reference it + from ...config.settings import cfg + cfg.write_env( ACA_MI_RESOURCE_ID=mi_id, ACA_MI_CLIENT_ID=client_id, @@ -274,6 +272,8 @@ def provision_managed_identity( def revoke_managed_identity(self, resource_group: str) -> dict[str, Any]: """Delete the managed identity.""" + from ...config.settings import cfg + steps: list[dict[str, str]] = [] mi_id = cfg.env.read("ACA_MI_RESOURCE_ID") if not mi_id: @@ -288,6 +288,8 @@ def revoke_managed_identity(self, resource_group: str) -> dict[str, Any]: def status(self) -> dict[str, Any]: """Return current runtime identity state.""" + from ...config.settings import cfg + app_id = cfg.env.read("RUNTIME_SP_APP_ID") mi_client_id = cfg.env.read("ACA_MI_CLIENT_ID") return { @@ -300,26 +302,8 @@ def status(self) -> dict[str, Any]: } def _session_pool_scope(self, subscription_id: str) -> str | None: - """Return the ARM resource scope for the ACA session pool, or ``None``. - - The session pool id is stored in ``sandbox.json`` after provisioning. - """ - try: - store = SandboxConfigStore() - pool_id = store.pool_id - if pool_id: - return pool_id - # Fall back to constructing the scope from individual fields - rg = store.resource_group - name = store.pool_name - if rg and name: - return ( - f"/subscriptions/{subscription_id}/resourceGroups/{rg}" - f"/providers/Microsoft.App/sessionPools/{name}" - ) - except Exception as exc: - logger.debug("Could not resolve session pool scope: %s", exc) - return None + """Return the ARM resource scope for the ACA session pool, or ``None``.""" + return _session_pool_scope_fn(subscription_id) def _keyvault_scope(self, subscription_id: str) -> str | None: """Return the ARM resource scope for the Key Vault, or ``None``. @@ -329,6 +313,8 @@ def _keyvault_scope(self, subscription_id: str) -> str | None: scope ensures the KV Secrets Officer role grants access regardless of which RG the vault is in. """ + from ...config.settings import cfg + kv_name = cfg.env.read("KEY_VAULT_NAME") or "" kv_rg = cfg.env.read("KEY_VAULT_RG") or "" if kv_name and kv_rg: diff --git a/app/runtime/services/deployment/__init__.py b/app/runtime/services/deployment/__init__.py new file mode 100644 index 0000000..c5a29a1 --- /dev/null +++ b/app/runtime/services/deployment/__init__.py @@ -0,0 +1,9 @@ +"""Deployment and infrastructure provisioning.""" + +from __future__ import annotations + +from .aca_deployer import AcaDeployer +from .deployer import BotDeployer +from .provisioner import Provisioner + +__all__ = ["AcaDeployer", "BotDeployer", "Provisioner"] diff --git a/app/runtime/services/deployment/aca_deployer.py b/app/runtime/services/deployment/aca_deployer.py new file mode 100644 index 0000000..6cd44e3 --- /dev/null +++ b/app/runtime/services/deployment/aca_deployer.py @@ -0,0 +1,455 @@ +"""Azure Container Apps deployer.""" + +from __future__ import annotations + +import logging +import os +import time +from dataclasses import dataclass, field +from typing import Any + +from ...config.settings import cfg +from ...state.deploy_state import DeploymentRecord, DeployStateStore +from ..cloud.azure import AzureCLI +from ..cloud._azure_rbac import IMAGE_NAME as _IMAGE_NAME +from .aca_provision import ( + assign_rbac, + configure_ip_whitelist, + ensure_acr, + ensure_aca_environment, + ensure_managed_identity, + ensure_runtime_app, + get_acr_credentials, + push_image, +) + +logger = logging.getLogger(__name__) + +_LOCAL_IMAGE = "polyclaw:latest" + + +@dataclass +class AcaDeployRequest: + + resource_group: str = "polyclaw-rg" + location: str = "eastus" + bot_display_name: str = "polyclaw" + bot_handle: str = "" + admin_port: int = 9090 + runtime_port: int = 8080 + image_tag: str = "latest" + acr_name: str = "" + env_name: str = "" + + +@dataclass +class AcaDeployResult: + + ok: bool = False + steps: list[dict[str, Any]] = field(default_factory=list) + error: str = "" + runtime_fqdn: str = "" + acr_name: str = "" + deploy_id: str = "" + + +class AcaDeployer: + + def __init__(self, az: AzureCLI, deploy_store: DeployStateStore | None = None) -> None: + self._az = az + self._deploy_store = deploy_store + + def deploy(self, req: AcaDeployRequest) -> AcaDeployResult: + steps: list[dict[str, Any]] = [] + result = AcaDeployResult(steps=steps) + + logger.info("[aca] Starting ACA deployment: rg=%s, location=%s", req.resource_group, req.location) + + rec = DeploymentRecord.new(kind="aca") + result.deploy_id = rec.deploy_id + if self._deploy_store: + self._deploy_store.register(rec) + + try: + self._cleanup_stale_resources(req, steps) + + if not self._ensure_resource_group(req, steps, rec): + result.error = "Resource group creation failed" + return result + + env_vars = self._load_env_vars(steps) + + acr_name = ensure_acr(self._az, req.resource_group, req.location, steps, rec) + if not acr_name: + result.error = "Container registry creation failed" + return result + result.acr_name = acr_name + + if not push_image(self._az, acr_name, req.image_tag, steps): + result.error = "Image push failed" + return result + + acr_user, acr_pass = get_acr_credentials(self._az, acr_name) + if not acr_user: + result.error = "Could not retrieve ACR admin credentials" + return result + + mi_id, mi_client_id = ensure_managed_identity( + self._az, req.resource_group, req.location, steps, rec, + ) + if not mi_id: + result.error = "Managed identity creation failed" + return result + + assign_rbac(self._az, mi_client_id, req.resource_group, steps) + + env_name, env_id = ensure_aca_environment( + self._az, req.resource_group, req.location, steps, rec, + ) + if not env_name: + result.error = "Container Apps environment creation failed" + return result + + runtime_fqdn = ensure_runtime_app( + self._az, req.resource_group, env_id, acr_name, + mi_id, mi_client_id, acr_user, acr_pass, + env_vars, req.image_tag, req.runtime_port, steps, rec, + ) + if not runtime_fqdn: + result.error = "Runtime container app creation failed" + return result + result.runtime_fqdn = runtime_fqdn + + ip_steps = configure_ip_whitelist(self._az, req.resource_group) + steps.extend(ip_steps) + + runtime_url = f"https://{runtime_fqdn}" + cfg.write_env( + ACA_RUNTIME_FQDN=runtime_fqdn, + ACA_ACR_NAME=acr_name, + ACA_ENV_NAME=env_name, + ACA_MI_RESOURCE_ID=mi_id, + ACA_MI_CLIENT_ID=mi_client_id, + RUNTIME_URL=runtime_url, + ) + os.environ["RUNTIME_URL"] = runtime_url + logger.info("[aca] RUNTIME_URL set to %s", runtime_url) + steps.append({"step": "write_aca_config", "status": "ok"}) + + result.ok = True + logger.info("[aca] Deployment complete: runtime=%s", runtime_fqdn) + + except Exception as exc: + logger.error("[aca] Deployment failed: %s", exc, exc_info=True) + result.error = str(exc) + steps.append({"step": "unexpected_error", "status": "failed", "detail": str(exc)}) + + if self._deploy_store and rec: + if result.ok: + rec.config = { + "runtime_fqdn": result.runtime_fqdn, + "acr_name": result.acr_name, + } + else: + rec.mark_stopped() + self._deploy_store.update(rec) + + return result + + def destroy(self, deploy_id: str | None = None) -> AcaDeployResult: + steps: list[dict[str, Any]] = [] + result = AcaDeployResult(steps=steps) + + rec = None + if deploy_id and self._deploy_store: + rec = self._deploy_store.get(deploy_id) + elif self._deploy_store: + rec = self._deploy_store.current_aca() + + rg = ( + cfg.env.read("BOT_RESOURCE_GROUP") + or (rec.resource_groups[0] if rec and rec.resource_groups else "") + ) + + if rg: + cleaned = self._delete_aca_resources(rg, steps, step_label="destroy") + if cleaned: + logger.info("[aca] Destroyed %d resource(s): %s", + len(cleaned), ", ".join(cleaned)) + else: + logger.info("[aca] No ACA resources found to destroy in %s", rg) + + cfg.write_env( + ACA_RUNTIME_FQDN="", + ACA_ACR_NAME="", ACA_ENV_NAME="", + ACA_MI_RESOURCE_ID="", + ACA_MI_CLIENT_ID="", + RUNTIME_URL="", + ) + steps.append({"step": "clear_aca_config", "status": "ok"}) + + if rec and self._deploy_store: + rec.mark_destroyed() + self._deploy_store.update(rec) + + result.ok = True + return result + + def status(self) -> dict[str, Any]: + runtime_fqdn = cfg.env.read("ACA_RUNTIME_FQDN") + return { + "deployed": bool(runtime_fqdn), + "runtime_fqdn": runtime_fqdn or None, + "acr_name": cfg.env.read("ACA_ACR_NAME") or None, + "env_name": cfg.env.read("ACA_ENV_NAME") or None, + "mi_client_id": cfg.env.read("ACA_MI_CLIENT_ID") or None, + } + + def restart(self) -> dict[str, Any]: + rg = cfg.env.read("BOT_RESOURCE_GROUP") or "polyclaw-rg" + app_name = "polyclaw-runtime" + + revisions = self._az.json( + "containerapp", "revision", "list", + "--name", app_name, + "--resource-group", rg, + quiet=True, + ) + if not revisions or not isinstance(revisions, list): + ok, msg = self._az.ok( + "containerapp", "update", + "--name", app_name, + "--resource-group", rg, + "--set-env-vars", f"RESTART_TS={int(time.time())}", + ) + result_detail = { + "app": app_name, + "status": "ok" if ok else "failed", + "method": "update", + "detail": msg if not ok else "forced new revision", + } + logger.info("[aca.restart] result=%r", result_detail) + return {"ok": ok, "results": [result_detail]} + + active = next( + (r["name"] for r in revisions if r.get("properties", {}).get("active")), + revisions[0].get("name") if revisions else None, + ) + if not active: + result_detail = { + "app": app_name, "status": "failed", + "method": "revision_restart", + "detail": "no active revision found", + } + logger.info("[aca.restart] result=%r", result_detail) + return {"ok": False, "results": [result_detail]} + + ok, msg = self._az.ok( + "containerapp", "revision", "restart", + "--name", app_name, + "--resource-group", rg, + "--revision", active, + ) + result_detail = { + "app": app_name, + "status": "ok" if ok else "failed", + "method": "revision_restart", + "detail": active if ok else msg, + } + logger.info("[aca.restart] result=%r", result_detail) + return {"ok": ok, "results": [result_detail]} + + def _delete_aca_resources( + self, rg: str, steps: list[dict], *, step_label: str = "cleanup", + ) -> list[str]: + rg_exists = self._az.json("group", "show", "--name", rg, quiet=True) + if not isinstance(rg_exists, dict): + logger.info("[aca] Resource group %s does not exist -- nothing to clean", rg) + return [] + + cleaned: list[str] = [] + + apps = self._az.json( + "containerapp", "list", + "--resource-group", rg, quiet=True, + ) + for app in (apps if isinstance(apps, list) else []): + name = app.get("name", "") + if not name: + continue + logger.info("[aca] Deleting container app: %s (waiting)", name) + ok, _ = self._az.ok( + "containerapp", "delete", "--name", name, + "--resource-group", rg, "--yes", + ) + if ok: + cleaned.append(f"containerapp/{name}") + steps.append({"step": f"{step_label}/containerapp/{name}", + "status": "ok" if ok else "failed"}) + + identities = self._az.json( + "identity", "list", + "--resource-group", rg, quiet=True, + ) + for mi in (identities if isinstance(identities, list) else []): + name = mi.get("name", "") + if not name: + continue + logger.info("[aca] Deleting managed identity: %s (waiting)", name) + ok, _ = self._az.ok( + "identity", "delete", "--name", name, + "--resource-group", rg, + ) + if ok: + cleaned.append(f"identity/{name}") + steps.append({"step": f"{step_label}/identity/{name}", + "status": "ok" if ok else "failed"}) + + envs = self._az.json( + "containerapp", "env", "list", + "--resource-group", rg, quiet=True, + ) + for env in (envs if isinstance(envs, list) else []): + name = env.get("name", "") + if not name: + continue + logger.info("[aca] Deleting ACA environment: %s (no-wait)", name) + ok, _ = self._az.ok( + "containerapp", "env", "delete", "--name", name, + "--resource-group", rg, "--yes", "--no-wait", + ) + if ok: + cleaned.append(f"aca-env/{name}") + steps.append({"step": f"{step_label}/aca-env/{name}", + "status": "ok" if ok else "failed"}) + + acrs = self._az.json( + "acr", "list", + "--resource-group", rg, quiet=True, + ) + for acr in (acrs if isinstance(acrs, list) else []): + name = acr.get("name", "") + if not name: + continue + logger.info("[aca] Deleting ACR: %s", name) + ok, _ = self._az.ok( + "acr", "delete", "--name", name, + "--resource-group", rg, "--yes", + ) + if ok: + cleaned.append(f"acr/{name}") + steps.append({"step": f"{step_label}/acr/{name}", + "status": "ok" if ok else "failed"}) + + workspaces = self._az.json( + "monitor", "log-analytics", "workspace", "list", + "--resource-group", rg, quiet=True, + ) + for ws in (workspaces if isinstance(workspaces, list) else []): + name = ws.get("name", "") + if not name: + continue + logger.info("[aca] Deleting Log Analytics workspace: %s", name) + ok, _ = self._az.ok( + "monitor", "log-analytics", "workspace", "delete", + "--workspace-name", name, + "--resource-group", rg, "--yes", "--force", + ) + if ok: + cleaned.append(f"log-analytics/{name}") + steps.append({"step": f"{step_label}/log-analytics/{name}", + "status": "ok" if ok else "failed"}) + + storage_accounts = self._az.json( + "storage", "account", "list", + "--resource-group", rg, quiet=True, + ) + for sa in (storage_accounts if isinstance(storage_accounts, list) else []): + name = sa.get("name", "") + if not name: + continue + tags = sa.get("tags", {}) or {} + kind = sa.get("kind", "") + if "polyclaw_deploy" in tags or kind == "StorageV2": + logger.info("[aca] Deleting storage account: %s", name) + ok, _ = self._az.ok( + "storage", "account", "delete", "--name", name, + "--resource-group", rg, "--yes", + ) + if ok: + cleaned.append(f"storage/{name}") + steps.append({"step": f"{step_label}/storage/{name}", + "status": "ok" if ok else "failed"}) + + return cleaned + + def _cleanup_stale_resources( + self, req: AcaDeployRequest, steps: list[dict], + ) -> None: + logger.info("[aca] Pre-flight: cleaning all ACA resources in %s ...", req.resource_group) + cleaned = self._delete_aca_resources(req.resource_group, steps, step_label="cleanup") + if cleaned: + detail = ", ".join(cleaned) + logger.info("[aca] Cleaned %d resource(s): %s", len(cleaned), detail) + else: + logger.info("[aca] No resources to clean") + steps.append({"step": "cleanup", "status": "ok", "detail": "nothing to clean"}) + + def _ensure_resource_group( + self, req: AcaDeployRequest, steps: list[dict], rec: DeploymentRecord, + ) -> bool: + logger.info("[aca] Step 1/10: Ensuring resource group %s ...", req.resource_group) + tag_args = ["--tags", f"polyclaw_deploy={rec.tag}"] + result = self._az.json( + "group", "create", "--name", req.resource_group, + "--location", req.location, *tag_args, + ) + if result: + steps.append({"step": "resource_group", "status": "ok", "detail": req.resource_group}) + if req.resource_group not in rec.resource_groups: + rec.resource_groups.append(req.resource_group) + return True + steps.append({"step": "resource_group", "status": "failed", "detail": self._az.last_stderr}) + return False + + def _load_env_vars(self, steps: list[dict]) -> dict[str, str]: + from ..keyvault import is_kv_ref, kv + + env_map = cfg.env.read_all() + _DEPLOYER_KEYS = frozenset({ + "ACA_RUNTIME_FQDN", "ACA_ACR_NAME", "ACA_ENV_NAME", + "ACA_STORAGE_ACCOUNT", "ACA_MI_RESOURCE_ID", "ACA_MI_CLIENT_ID", + "RUNTIME_URL", + }) + filtered = {k: v for k, v in env_map.items() if k not in _DEPLOYER_KEYS and v} + + resolved_count = 0 + for key, value in list(filtered.items()): + if is_kv_ref(value): + try: + plaintext = kv.resolve_value(value) + if plaintext: + filtered[key] = plaintext + resolved_count += 1 + logger.info("[aca] Resolved @kv: ref for %s", key) + else: + logger.warning( + "[aca] @kv: ref for %s resolved to empty -- removing", key, + ) + del filtered[key] + except Exception: + logger.error( + "[aca] Failed to resolve @kv: ref for %s -- removing", + key, exc_info=True, + ) + del filtered[key] + + count = len(filtered) + logger.info( + "[aca] Step 2/10: Loaded %d env var(s) from local .env " + "(%d @kv: references resolved)", + count, resolved_count, + ) + steps.append({"step": "load_env_vars", "status": "ok", + "detail": f"{count} variable(s), {resolved_count} @kv: resolved"}) + return filtered diff --git a/app/runtime/services/deployment/aca_provision.py b/app/runtime/services/deployment/aca_provision.py new file mode 100644 index 0000000..00c0ed5 --- /dev/null +++ b/app/runtime/services/deployment/aca_provision.py @@ -0,0 +1,433 @@ +"""ACA resource provisioning helpers for the deployer.""" + +from __future__ import annotations + +import logging +import secrets +import subprocess +import time +from typing import Any + +from ...config.settings import cfg +from ...state.deploy_state import DeploymentRecord +from ..cloud.azure import AzureCLI +from ..cloud._azure_rbac import ( + BOT_CONTRIBUTOR_ROLE as _BOT_CONTRIBUTOR_ROLE, + IMAGE_NAME as _IMAGE_NAME, + MI_NAME as _MI_NAME, + RG_READER_ROLE as _RG_READER_ROLE, + SESSION_EXECUTOR_ROLE as _SESSION_EXECUTOR_ROLE, + session_pool_scope as _session_pool_scope, +) + +logger = logging.getLogger(__name__) + +_ENV_NAME_PREFIX = "polyclaw-env" + + +def ensure_acr( + az: AzureCLI, + resource_group: str, + location: str, + steps: list[dict], + rec: DeploymentRecord, +) -> str: + """Create a container registry. Returns the ACR name, or ``""`` on failure.""" + logger.info("[aca] Step 3/10: Creating container registry ...") + acr_name = "polyclaw" + secrets.token_hex(4) + acr_name = acr_name[:50].replace("-", "") + + result = az.json( + "acr", "create", + "--resource-group", resource_group, + "--name", acr_name, + "--sku", "Basic", + "--admin-enabled", "true", + "--location", location, + ) + if not result: + steps.append({ + "step": "acr_create", "status": "failed", + "detail": az.last_stderr, + }) + return "" + steps.append({"step": "acr_create", "status": "ok", "detail": acr_name}) + rec.add_resource("acr", resource_group, acr_name, "Container registry") + return acr_name + + +def get_acr_credentials(az: AzureCLI, acr_name: str) -> tuple[str, str]: + """Return ``(username, password)`` for the ACR admin account.""" + creds = az.json("acr", "credential", "show", "--name", acr_name) + if not isinstance(creds, dict): + return "", "" + username = creds.get("username", "") + passwords = creds.get("passwords", []) + password = passwords[0].get("value", "") if passwords else "" + return username, password + + +def push_image( + az: AzureCLI, + acr_name: str, + tag: str, + steps: list[dict], +) -> bool: + """Build, tag, and push the local Docker image to ACR.""" + logger.info("[aca] Step 4/10: Pushing pre-built image to ACR ...") + local_image = f"{_IMAGE_NAME}:{tag}" + remote_image = f"{acr_name}.azurecr.io/{_IMAGE_NAME}:{tag}" + + check = subprocess.run( + ["docker", "image", "inspect", local_image], + capture_output=True, text=True, + ) + if check.returncode != 0: + detail = ( + f"Local image '{local_image}' not found. " + "Build it first with: docker build --platform linux/amd64 " + f"-t {local_image} ." + ) + logger.error("[aca] %s", detail) + steps.append({"step": "image_push", "status": "failed", "detail": detail}) + return False + + logger.info("[aca] Logging in to ACR %s ...", acr_name) + ok, msg = az.ok("acr", "login", "--name", acr_name) + if not ok: + detail = f"ACR login failed: {msg or az.last_stderr}" + logger.error("[aca] %s", detail) + steps.append({"step": "image_push", "status": "failed", "detail": detail}) + return False + + logger.info("[aca] Tagging %s -> %s", local_image, remote_image) + tag_result = subprocess.run( + ["docker", "tag", local_image, remote_image], + capture_output=True, text=True, + ) + if tag_result.returncode != 0: + detail = f"docker tag failed: {tag_result.stderr.strip()}" + logger.error("[aca] %s", detail) + steps.append({"step": "image_push", "status": "failed", "detail": detail}) + return False + + logger.info("[aca] Pushing %s (this may take 1-2 minutes) ...", remote_image) + push_result = subprocess.run( + ["docker", "push", remote_image], + capture_output=True, text=True, timeout=600, + ) + if push_result.returncode != 0: + detail = f"docker push failed: {push_result.stderr.strip()[:500]}" + logger.error("[aca] %s", detail) + steps.append({"step": "image_push", "status": "failed", "detail": detail}) + return False + + logger.info("[aca] Image pushed: %s", remote_image) + steps.append({"step": "image_push", "status": "ok", "detail": remote_image}) + return True + + +def ensure_managed_identity( + az: AzureCLI, + resource_group: str, + location: str, + steps: list[dict], + rec: DeploymentRecord, +) -> tuple[str, str]: + """Create a user-assigned managed identity. Returns ``(id, client_id)``.""" + logger.info("[aca] Step 5/10: Creating managed identity ...") + result = az.json( + "identity", "create", + "--name", _MI_NAME, + "--resource-group", resource_group, + "--location", location, + ) + if not isinstance(result, dict): + steps.append({"step": "managed_identity", "status": "failed", + "detail": az.last_stderr}) + return "", "" + + mi_id = result.get("id", "") + client_id = result.get("clientId", "") + steps.append({"step": "managed_identity", "status": "ok", "detail": _MI_NAME}) + rec.add_resource("managed_identity", resource_group, _MI_NAME, + "Runtime scoped identity") + return mi_id, client_id + + +def assign_rbac( + az: AzureCLI, + mi_principal_id: str, + resource_group: str, + steps: list[dict], +) -> None: + """Assign RBAC roles to the managed identity.""" + logger.info("[aca] Step 6/10: Assigning RBAC ...") + account = az.account_info() + sub_id = account.get("id", "") if account else "" + rg_scope = f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" + + for role in (_BOT_CONTRIBUTOR_ROLE, _RG_READER_ROLE): + label = role.lower().replace(" ", "_") + assigned = False + for attempt in range(4): + if attempt: + delay = 10 * attempt + logger.info( + "[aca] RBAC retry %d/3 for %s in %ds ...", + attempt, label, delay, + ) + time.sleep(delay) + ok, _msg = az.ok( + "role", "assignment", "create", + "--assignee", mi_principal_id, + "--role", role, + "--scope", rg_scope, + ) + if ok or "already exists" in (az.last_stderr or "").lower(): + assigned = True + break + if assigned: + steps.append({"step": f"rbac_{label}", "status": "ok", + "detail": f"{role} on {resource_group}"}) + else: + steps.append({"step": f"rbac_{label}", "status": "failed", + "detail": az.last_stderr}) + + session_scope = _session_pool_scope(sub_id) + if session_scope: + label = _SESSION_EXECUTOR_ROLE.lower().replace(" ", "_") + assigned = False + for attempt in range(4): + if attempt: + delay = 10 * attempt + logger.info( + "[aca] RBAC retry %d/3 for %s in %ds ...", + attempt, label, delay, + ) + time.sleep(delay) + ok, _msg = az.ok( + "role", "assignment", "create", + "--assignee", mi_principal_id, + "--role", _SESSION_EXECUTOR_ROLE, + "--scope", session_scope, + ) + if ok or "already exists" in (az.last_stderr or "").lower(): + assigned = True + break + if assigned: + steps.append({"step": f"rbac_{label}", "status": "ok", + "detail": f"{_SESSION_EXECUTOR_ROLE} on session pool"}) + else: + steps.append({"step": f"rbac_{label}", "status": "failed", + "detail": az.last_stderr}) + + +def ensure_aca_environment( + az: AzureCLI, + resource_group: str, + location: str, + steps: list[dict], + rec: DeploymentRecord, +) -> tuple[str, str]: + """Create an ACA environment. Returns ``(env_name, env_id)``.""" + logger.info("[aca] Step 7/10: Creating ACA environment ...") + env_name = f"{_ENV_NAME_PREFIX}-{secrets.token_hex(4)}" + + result = az.json( + "containerapp", "env", "create", + "--name", env_name, + "--resource-group", resource_group, + "--location", location, + ) + if not isinstance(result, dict): + steps.append({ + "step": "aca_environment", "status": "failed", + "detail": az.last_stderr, + }) + return "", "" + + env_id = result.get("id", "") + steps.append({"step": "aca_environment", "status": "ok", "detail": env_name}) + rec.add_resource("aca_environment", resource_group, env_name, + "Container Apps environment") + return env_name, env_id + + +def ensure_runtime_app( + az: AzureCLI, + resource_group: str, + env_id: str, + acr_name: str, + mi_id: str, + mi_client_id: str, + acr_user: str, + acr_pass: str, + env_vars: dict[str, str], + image_tag: str, + runtime_port: int, + steps: list[dict], + rec: DeploymentRecord, +) -> str: + """Create the runtime container app. Returns the FQDN, or ``""`` on failure.""" + app_name = "polyclaw-runtime" + admin_secret = cfg.admin_secret or secrets.token_urlsafe(24) + image = f"{acr_name}.azurecr.io/{_IMAGE_NAME}:{image_tag}" + + logger.info("[aca] Step 8/10: Creating runtime container app ...") + + _SECRET_ENV_KEYS = frozenset({ + "RUNTIME_SP_PASSWORD", "ACS_CALLBACK_TOKEN", + "GITHUB_TOKEN", "BOT_APP_PASSWORD", + "ACS_CONNECTION_STRING", "AZURE_OPENAI_API_KEY", + }) + _SKIP = frozenset({ + "POLYCLAW_MODE", "POLYCLAW_DATA_DIR", "ADMIN_PORT", + "ADMIN_SECRET", "POLYCLAW_CONTAINER", "POLYCLAW_USE_MI", + "AZURE_CLIENT_ID", + }) | _SECRET_ENV_KEYS + aca_secrets: dict[str, str] = { + "admin-secret": admin_secret, + } + for env_key in _SECRET_ENV_KEYS: + secret_name = env_key.lower().replace("_", "-") + value = env_vars.get(env_key, "") + if value: + aca_secrets[secret_name] = value + + env_pairs = [ + "POLYCLAW_MODE=runtime", + f"ADMIN_PORT={runtime_port}", + "ADMIN_SECRET=secretref:admin-secret", + "POLYCLAW_CONTAINER=1", + "POLYCLAW_USE_MI=1", + f"AZURE_CLIENT_ID={mi_client_id}", + ] + for env_key in sorted(_SECRET_ENV_KEYS): + secret_name = env_key.lower().replace("_", "-") + if secret_name in aca_secrets: + env_pairs.append(f"{env_key}=secretref:{secret_name}") + + for key, value in sorted(env_vars.items()): + if key not in _SKIP and value: + env_pairs.append(f"{key}={value}") + + logger.info("[aca] Container env vars: %d total (%d via ACA secrets)", + len(env_pairs), len(aca_secrets)) + + secret_pairs = [f"{name}={value}" for name, value in sorted(aca_secrets.items())] + + create_args: list[str] = [ + "containerapp", "create", + "--name", app_name, + "--resource-group", resource_group, + "--environment", env_id, + "--image", image, + "--cpu", "2", "--memory", "4Gi", + "--min-replicas", "1", "--max-replicas", "1", + "--ingress", "external", + "--target-port", str(runtime_port), + "--registry-server", f"{acr_name}.azurecr.io", + "--registry-username", acr_user, + "--registry-password", acr_pass, + "--secrets", *secret_pairs, + "--env-vars", *env_pairs, + ] + + result = az.json(*create_args) + if not isinstance(result, dict): + detail = az.last_stderr + logger.error("[aca] containerapp create failed: %s", detail[:1000]) + steps.append({ + "step": "runtime_container_app", "status": "failed", + "detail": detail[:500], + }) + return "" + + logger.info("[aca] Assigning managed identity to container app ...") + id_ok, id_msg = az.ok( + "containerapp", "identity", "assign", + "--name", app_name, + "--resource-group", resource_group, + "--user-assigned", mi_id, + ) + if not id_ok: + logger.warning("[aca] MI assignment failed (non-fatal): %s", id_msg) + + fqdn = result.get("properties", {}).get("configuration", {}).get( + "ingress", {} + ).get("fqdn", "") + + if fqdn: + bot_endpoint = f"https://{fqdn}/api/messages" + az.ok( + "containerapp", "update", + "--name", app_name, + "--resource-group", resource_group, + "--set-env-vars", f"BOT_ENDPOINT={bot_endpoint}", + ) + + steps.append({"step": "runtime_container_app", "status": "ok", "detail": fqdn}) + rec.add_resource("container_app", resource_group, app_name, + "Runtime data plane (MI-scoped)") + return fqdn + + +def configure_ip_whitelist( + az: AzureCLI, + resource_group: str, +) -> list[dict[str, Any]]: + """Restrict the runtime container's ingress to the deployer's IP.""" + ip_steps: list[dict[str, Any]] = [] + + public_ip = detect_public_ip() + if not public_ip: + ip_steps.append({ + "step": "ip_whitelist", + "status": "skipped", + "detail": "Could not detect public IP -- runtime ingress unrestricted", + }) + return ip_steps + + ok, msg = az.ok( + "containerapp", "ingress", "access-restriction", "set", + "--name", "polyclaw-runtime", + "--resource-group", resource_group, + "--rule-name", "allow-deployer", + "--ip-address", f"{public_ip}/32", + "--action", "Allow", + "--description", "Allow deployer IP", + ) + if ok: + ip_steps.append({ + "step": "ip_whitelist", + "status": "ok", + "detail": f"Runtime restricted to {public_ip}/32", + }) + else: + ip_steps.append({ + "step": "ip_whitelist", + "status": "warning", + "detail": f"Could not set IP restriction: {msg}", + }) + + return ip_steps + + +def detect_public_ip() -> str: + """Return the deployer's public IP address, or ``""`` if unavailable.""" + import urllib.request + + for url in ( + "https://api.ipify.org", + "https://ifconfig.me/ip", + "https://checkip.amazonaws.com", + ): + try: + with urllib.request.urlopen(url, timeout=10) as resp: + ip = resp.read().decode().strip() + if ip and "." in ip: + return ip + except Exception: + continue + return "" diff --git a/app/runtime/services/deployer.py b/app/runtime/services/deployment/deployer.py similarity index 99% rename from app/runtime/services/deployer.py rename to app/runtime/services/deployment/deployer.py index a83518e..035f576 100644 --- a/app/runtime/services/deployer.py +++ b/app/runtime/services/deployment/deployer.py @@ -9,9 +9,9 @@ from dataclasses import dataclass from typing import Any -from ..config.settings import cfg -from ..state.deploy_state import DeployStateStore -from .azure import AzureCLI +from ...config.settings import cfg +from ...state.deploy_state import DeployStateStore +from ..cloud.azure import AzureCLI logger = logging.getLogger(__name__) diff --git a/app/runtime/services/provisioner.py b/app/runtime/services/deployment/provisioner.py similarity index 97% rename from app/runtime/services/provisioner.py rename to app/runtime/services/deployment/provisioner.py index 02c2153..d83fd30 100644 --- a/app/runtime/services/provisioner.py +++ b/app/runtime/services/deployment/provisioner.py @@ -5,12 +5,12 @@ import logging from typing import Any -from ..config.settings import cfg -from ..state.deploy_state import DeploymentRecord, DeployStateStore -from ..state.infra_config import InfraConfigStore -from .azure import AzureCLI +from ...config.settings import cfg +from ...state.deploy_state import DeploymentRecord, DeployStateStore +from ...state.infra_config import InfraConfigStore +from ..cloud.azure import AzureCLI from .deployer import BotDeployer, DeployRequest -from .runtime_identity import RuntimeIdentityProvisioner +from ..cloud.runtime_identity import RuntimeIdentityProvisioner logger = logging.getLogger(__name__) diff --git a/app/runtime/services/keyvault.py b/app/runtime/services/keyvault.py index a88d714..a64fab1 100644 --- a/app/runtime/services/keyvault.py +++ b/app/runtime/services/keyvault.py @@ -205,6 +205,16 @@ def _allow_current_ip(self) -> bool: kv = KeyVaultClient() +def _reset_kv() -> None: + """Reset the module-level Key Vault singleton (for test isolation).""" + kv.reinit() + + +from ..util.singletons import register_singleton # noqa: E402 + +register_singleton(_reset_kv) + + def resolve_if_kv_ref(value: str) -> str: """Resolve a ``@kv:secret-name`` reference, returning the original value if not a ref. diff --git a/app/runtime/services/otel.py b/app/runtime/services/otel.py index eaf9260..4997579 100644 --- a/app/runtime/services/otel.py +++ b/app/runtime/services/otel.py @@ -31,6 +31,11 @@ def _reset_otel_state() -> None: _otel_active = False +from ..util.singletons import register_singleton # noqa: E402 + +register_singleton(_reset_otel_state) + + def configure_otel( connection_string: str, *, diff --git a/app/runtime/services/resource_tracker.py b/app/runtime/services/resource_tracker.py index 50cda5d..aab7819 100644 --- a/app/runtime/services/resource_tracker.py +++ b/app/runtime/services/resource_tracker.py @@ -7,7 +7,7 @@ from typing import Any from ..state.deploy_state import TAG_PREFIX, DeployStateStore -from .azure import AzureCLI +from .cloud.azure import AzureCLI logger = logging.getLogger(__name__) diff --git a/app/runtime/services/security/__init__.py b/app/runtime/services/security/__init__.py new file mode 100644 index 0000000..b43820d --- /dev/null +++ b/app/runtime/services/security/__init__.py @@ -0,0 +1,9 @@ +"""Security, content safety, and misconfiguration checking.""" + +from __future__ import annotations + +from .misconfig_checker import MisconfigChecker +from .prompt_shield import PromptShieldService +from .security_preflight import SecurityPreflightChecker + +__all__ = ["MisconfigChecker", "PromptShieldService", "SecurityPreflightChecker"] diff --git a/app/runtime/services/misconfig_checker.py b/app/runtime/services/security/misconfig_checker.py similarity index 99% rename from app/runtime/services/misconfig_checker.py rename to app/runtime/services/security/misconfig_checker.py index 660e62e..9e7cdb7 100644 --- a/app/runtime/services/misconfig_checker.py +++ b/app/runtime/services/security/misconfig_checker.py @@ -6,7 +6,7 @@ from dataclasses import asdict, dataclass, field from typing import Any, Literal -from .azure import AzureCLI +from ..cloud.azure import AzureCLI logger = logging.getLogger(__name__) diff --git a/app/runtime/services/security/preflight_identity.py b/app/runtime/services/security/preflight_identity.py new file mode 100644 index 0000000..4cb8c5a --- /dev/null +++ b/app/runtime/services/security/preflight_identity.py @@ -0,0 +1,240 @@ +"""Identity preflight checks (login gate, identity config, validity, credential expiry).""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from ...config.settings import cfg +from ..cloud.azure import AzureCLI +from .security_preflight import ( + IdentityInfo, + PreflightCheck, + PreflightResult, + add_check as _add, +) + + +# -- Azure login gate --------------------------------------------------- + +def check_azure_logged_in(az: AzureCLI, result: PreflightResult) -> bool: + cmd = "az account show" + account = az.json("account", "show", quiet=True) + if isinstance(account, dict) and account.get("id"): + sub = account.get("name", account.get("id", "?")) + _add( + result, id="azure_logged_in", category="identity", + name="Azure CLI Authenticated", + status="pass", + detail=f"Logged in to subscription: {sub}", + evidence=f"subscription={sub}\ntenantId={account.get('tenantId', '?')}", + command=cmd, + ) + return True + _add( + result, id="azure_logged_in", category="identity", + name="Azure CLI Authenticated", + status="fail", + detail="Not logged in -- RBAC and identity checks require Azure CLI auth", + evidence=az.last_stderr or "No response", + command=cmd, + ) + return False + + +def skip_azure_checks(result: PreflightResult) -> None: + for check_id, name, cat in [ + ("identity_configured", "Runtime Identity Configured", "identity"), + ("identity_valid", "Identity Exists in Azure AD", "identity"), + ("identity_credential_expiry", "Credential Expiry", "identity"), + ("rbac_assignments_list", "RBAC Assignments", "rbac"), + ("rbac_bot_contributor", "Azure Bot Service Contributor Role", "rbac"), + ("rbac_reader", "Reader Role", "rbac"), + ("rbac_kv_access", "Key Vault Access Role", "rbac"), + ("rbac_session_pool", "Session Pool Executor", "rbac"), + ("rbac_no_elevated", "No Elevated Roles", "rbac"), + ("rbac_scope_contained", "Scope Limited to Resource Group", "rbac"), + ]: + _add( + result, id=check_id, category=cat, name=name, + status="skip", + detail="Skipped -- Azure CLI not authenticated", + command="", + ) + + +# -- Identity checks ---------------------------------------------------- + +def check_identity_configured( + az: AzureCLI, result: PreflightResult, +) -> IdentityInfo | None: + sp_app_id = cfg.env.read("RUNTIME_SP_APP_ID") + mi_client_id = cfg.env.read("ACA_MI_CLIENT_ID") + mi_resource_id = cfg.env.read("ACA_MI_RESOURCE_ID") + + if mi_client_id: + _add( + result, id="identity_configured", category="identity", + name="Runtime Identity Configured", + status="pass", + detail=f"User-assigned managed identity: client_id={mi_client_id}", + evidence=( + f"ACA_MI_CLIENT_ID={mi_client_id}\n" + f"ACA_MI_RESOURCE_ID={mi_resource_id}" + ), + command="env: ACA_MI_CLIENT_ID, ACA_MI_RESOURCE_ID", + ) + return { + "strategy": "managed_identity", + "client_id": mi_client_id, + "resource_id": mi_resource_id, + "assignee": mi_client_id, + } + + if sp_app_id: + sp_tenant = cfg.env.read("RUNTIME_SP_TENANT") + has_pw = bool(cfg.env.read("RUNTIME_SP_PASSWORD")) + _add( + result, id="identity_configured", category="identity", + name="Runtime Identity Configured", + status="pass", + detail=f"Scoped service principal: app_id={sp_app_id}", + evidence=( + f"RUNTIME_SP_APP_ID={sp_app_id}\n" + f"RUNTIME_SP_TENANT={sp_tenant}\n" + f"RUNTIME_SP_PASSWORD={'***' if has_pw else 'MISSING'}" + ), + command="env: RUNTIME_SP_APP_ID, RUNTIME_SP_TENANT, RUNTIME_SP_PASSWORD", + ) + return { + "strategy": "sp", + "app_id": sp_app_id, + "tenant": sp_tenant, + "assignee": sp_app_id, + } + + _add( + result, id="identity_configured", category="identity", + name="Runtime Identity Configured", + status="skip", + detail="No runtime identity configured (RUNTIME_SP_* and ACA_MI_* absent)", + evidence="RUNTIME_SP_APP_ID=(empty)\nACA_MI_CLIENT_ID=(empty)", + command="env: RUNTIME_SP_APP_ID, ACA_MI_CLIENT_ID", + ) + return None + + +def check_identity_valid( + az: AzureCLI, result: PreflightResult, info: IdentityInfo, +) -> None: + if info["strategy"] == "sp": + app_id = info["app_id"] + cmd = f"az ad sp show --id {app_id}" + sp = az.json("ad", "sp", "show", "--id", app_id) + if isinstance(sp, dict) and sp.get("appId"): + display = sp.get("displayName", "?") + _add( + result, id="identity_valid", category="identity", + name="Service Principal Exists in Azure AD", + status="pass", + detail=f"{display} ({app_id})", + evidence=( + f"displayName={display}\n" + f"appId={app_id}\n" + f"objectId={sp.get('id', '?')}" + ), + command=cmd, + ) + else: + _add( + result, id="identity_valid", category="identity", + name="Service Principal Exists in Azure AD", + status="fail", + detail=f"SP not found: {app_id}", + evidence=az.last_stderr or "No response", + command=cmd, + ) + else: + resource_id = info.get("resource_id", "") + if not resource_id: + _add( + result, id="identity_valid", category="identity", + name="Managed Identity Exists", + status="skip", detail="No MI resource ID configured", + command="", + ) + return + cmd = f"az identity show --ids {resource_id}" + mi = az.json("identity", "show", "--ids", resource_id) + if isinstance(mi, dict) and mi.get("clientId"): + _add( + result, id="identity_valid", category="identity", + name="Managed Identity Exists", + status="pass", + detail=f"{mi.get('name', '?')} (client={mi.get('clientId', '?')})", + evidence=( + f"name={mi.get('name', '?')}\n" + f"clientId={mi.get('clientId', '?')}\n" + f"principalId={mi.get('principalId', '?')}" + ), + command=cmd, + ) + else: + _add( + result, id="identity_valid", category="identity", + name="Managed Identity Exists", + status="fail", + detail=f"MI not found: {resource_id}", + evidence=az.last_stderr or "No response", + command=cmd, + ) + + +def check_credential_expiry( + az: AzureCLI, result: PreflightResult, info: IdentityInfo, +) -> None: + if info["strategy"] != "sp": + _add( + result, id="identity_credential_expiry", category="identity", + name="Credential Expiry", + status="pass", + detail="Managed identities do not have expiring credentials", + command="(not applicable for MI)", + ) + return + + app_id = info["app_id"] + cmd = f"az ad app credential list --id {app_id}" + creds = az.json("ad", "app", "credential", "list", "--id", app_id) + if not isinstance(creds, list) or not creds: + _add( + result, id="identity_credential_expiry", category="identity", + name="Credential Expiry", + status="warn", + detail="Could not retrieve credential list", + evidence=az.last_stderr or "Empty response", + command=cmd, + ) + return + + latest = max(creds, key=lambda c: c.get("endDateTime", "")) + end = latest.get("endDateTime", "") + now = datetime.now(timezone.utc).isoformat() + + if end and end > now: + _add( + result, id="identity_credential_expiry", category="identity", + name="Credential Expiry", + status="pass", + detail=f"Valid until {end}", + evidence=f"endDateTime={end}\nnow={now}\ncredentials_count={len(creds)}", + command=cmd, + ) + else: + _add( + result, id="identity_credential_expiry", category="identity", + name="Credential Expiry", + status="fail", + detail=f"Credential EXPIRED: {end}", + evidence=f"endDateTime={end}\nnow={now}", + command=cmd, + ) diff --git a/app/runtime/services/security/preflight_rbac.py b/app/runtime/services/security/preflight_rbac.py new file mode 100644 index 0000000..e1f610a --- /dev/null +++ b/app/runtime/services/security/preflight_rbac.py @@ -0,0 +1,276 @@ +"""RBAC preflight checks.""" + +from __future__ import annotations + +from typing import Any + +from ..cloud.azure import AzureCLI +from .security_preflight import ( + IdentityInfo, + PreflightCheck, + PreflightResult, + _ELEVATED_ROLES, + add_check as _add, +) + + +def check_rbac_list( + az: AzureCLI, result: PreflightResult, info: IdentityInfo, +) -> list[dict[str, Any]] | None: + assignee = info.get("assignee", "") + if not assignee: + return None + + cmd = f"az role assignment list --assignee {assignee} --all" + assignments = az.json( + "role", "assignment", "list", "--assignee", assignee, "--all", + ) + if not isinstance(assignments, list): + _add( + result, id="rbac_assignments_list", category="rbac", + name="RBAC Assignments Retrieved", + status="fail", + detail="Could not list RBAC assignments", + evidence=az.last_stderr or "No response", + command=cmd, + ) + return None + + summary = ", ".join( + f"{a.get('roleDefinitionName', '?')} @ " + f"{a.get('scope', '?').rsplit('/', 1)[-1]}" + for a in assignments + ) + _add( + result, id="rbac_assignments_list", category="rbac", + name="RBAC Assignments Retrieved", + status="pass", + detail=f"{len(assignments)} assignment(s): {summary}", + evidence="\n".join( + f"- {a.get('roleDefinitionName', '?')} on {a.get('scope', '?')}" + for a in assignments + ), + command=cmd, + ) + return assignments + + +def check_rbac_has_role( + result: PreflightResult, + assignments: list[dict[str, Any]], + role_name: str, + check_id: str, + check_name: str, + bot_rg: str, + *, + missing_severity: str = "fail", + missing_detail: str = "", +) -> None: + matching = [ + a for a in assignments + if a.get("roleDefinitionName") == role_name + ] + if matching: + scopes = [a.get("scope", "") for a in matching] + _add( + result, id=check_id, category="rbac", + name=check_name, + status="pass", + detail=f"{role_name} assigned ({len(matching)} assignment(s))", + evidence="\n".join(f"scope={s}" for s in scopes), + command="Filtered from role assignment list", + ) + else: + detail = missing_detail or f"{role_name} NOT found in assignments" + _add( + result, id=check_id, category="rbac", + name=check_name, + status=missing_severity, + detail=detail, + evidence=( + f"Expected '{role_name}' but not present " + f"in {len(assignments)} assignment(s)" + ), + command="Filtered from role assignment list", + ) + + +def check_rbac_kv_access( + result: PreflightResult, + assignments: list[dict[str, Any]], + info: IdentityInfo, +) -> None: + kv_roles = [ + a for a in assignments + if "key vault" in (a.get("roleDefinitionName") or "").lower() + ] + + if not kv_roles: + _add( + result, id="rbac_kv_access", category="rbac", + name="Key Vault Access Role", + status="warn", + detail="No Key Vault role assignment found", + evidence=f"Checked {len(assignments)} assignments for 'Key Vault' roles", + command="Filtered from role assignment list", + ) + return + + role_names = [a.get("roleDefinitionName", "?") for a in kv_roles] + has_officer = "Key Vault Secrets Officer" in role_names + has_user = "Key Vault Secrets User" in role_names + + if info["strategy"] == "managed_identity": + if has_user and not has_officer: + status = "pass" + detail = "Key Vault Secrets User (read-only) -- correct for MI" + elif has_officer: + status = "warn" + detail = ( + "Key Vault Secrets Officer (read+write) -- " + "consider restricting to Secrets User for runtime" + ) + else: + status = "pass" + detail = f"Key Vault role: {', '.join(role_names)}" + else: + status = "pass" + detail = f"Key Vault role: {', '.join(role_names)}" + + _add( + result, id="rbac_kv_access", category="rbac", + name="Key Vault Access Role", + status=status, + detail=detail, + evidence="\n".join( + f"- {a.get('roleDefinitionName', '?')} on {a.get('scope', '?')}" + for a in kv_roles + ), + command="Filtered from role assignment list", + ) + + +def check_rbac_session_pool( + result: PreflightResult, assignments: list[dict[str, Any]], +) -> None: + from ...state.sandbox_config import SandboxConfigStore + + try: + sandbox_store = SandboxConfigStore() + sandbox_enabled = sandbox_store.enabled + sandbox_configured = sandbox_store.is_provisioned + except Exception: + sandbox_enabled = False + sandbox_configured = False + + matching = [ + a for a in assignments + if "session" in (a.get("roleDefinitionName") or "").lower() + ] + if matching: + names = [a.get("roleDefinitionName", "?") for a in matching] + _add( + result, id="rbac_session_pool", category="rbac", + name="Session Pool Executor", + status="pass", + detail=f"Session role: {', '.join(names)}", + evidence="\n".join( + f"scope={a.get('scope', '?')}" for a in matching + ), + command="Filtered from role assignment list", + ) + elif sandbox_enabled or sandbox_configured: + _add( + result, id="rbac_session_pool", category="rbac", + name="Session Pool Executor", + status="fail", + detail=( + "Azure ContainerApps Session Executor NOT found -- " + "required for sandbox (HTTP 403 on file upload/execute)" + ), + evidence=f"Not present in {len(assignments)} assignment(s)", + command="Filtered from role assignment list", + ) + else: + _add( + result, id="rbac_session_pool", category="rbac", + name="Session Pool Executor", + status="warn", + detail="ContainerApps Session Executor NOT found (needed if sandbox is enabled)", + evidence=f"Not present in {len(assignments)} assignment(s)", + command="Filtered from role assignment list", + ) + + +def check_rbac_no_elevated( + result: PreflightResult, assignments: list[dict[str, Any]], +) -> None: + elevated = [ + a for a in assignments + if a.get("roleDefinitionName") in _ELEVATED_ROLES + ] + if not elevated: + _add( + result, id="rbac_no_elevated", category="rbac", + name="No Elevated Roles", + status="pass", + detail="No Owner, Contributor, or User Access Administrator roles", + evidence=( + f"Checked {len(assignments)} assignment(s) against: " + f"{', '.join(sorted(_ELEVATED_ROLES))}" + ), + command="Filtered from role assignment list", + ) + else: + _add( + result, id="rbac_no_elevated", category="rbac", + name="No Elevated Roles", + status="fail", + detail=( + f"ELEVATED roles found: " + f"{', '.join(a.get('roleDefinitionName', '?') for a in elevated)}" + ), + evidence="\n".join( + f"- {a.get('roleDefinitionName', '?')} on {a.get('scope', '?')}" + for a in elevated + ), + command="Filtered from role assignment list", + ) + + +def check_rbac_scope_contained( + result: PreflightResult, assignments: list[dict[str, Any]], +) -> None: + out_of_scope = [ + a for a in assignments + if "/resourcegroups/" not in (a.get("scope") or "").lower() + ] + if not out_of_scope: + _add( + result, id="rbac_scope_contained", category="rbac", + name="Scope Limited to Resource Group", + status="pass", + detail=( + f"All {len(assignments)} assignment(s) scoped to " + f"resource group level or below" + ), + evidence="\n".join( + f"- {a.get('scope', '?')}" for a in assignments + ) if assignments else "No assignments", + command="Scope analysis from role assignment list", + ) + else: + _add( + result, id="rbac_scope_contained", category="rbac", + name="Scope Limited to Resource Group", + status="fail", + detail=( + f"{len(out_of_scope)} assignment(s) at subscription or management " + f"group level" + ), + evidence="\n".join( + f"- {a.get('roleDefinitionName', '?')} at {a.get('scope', '?')}" + for a in out_of_scope + ), + command="Scope analysis from role assignment list", + ) diff --git a/app/runtime/services/security/preflight_secrets.py b/app/runtime/services/security/preflight_secrets.py new file mode 100644 index 0000000..152be4f --- /dev/null +++ b/app/runtime/services/security/preflight_secrets.py @@ -0,0 +1,301 @@ +"""Secret-isolation preflight checks.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from ...config.settings import cfg +from .security_preflight import PreflightCheck, PreflightResult, add_check as _add + + +def run_secret_checks(result: PreflightResult) -> None: + """Execute all secret-isolation checks.""" + check_admin_cli_isolated(result) + check_no_github_in_runtime(result) + check_bot_credentials(result) + check_admin_secret(result) + check_kv_reachable(result) + check_acs_credential(result) + check_aoai_credential(result) + check_sp_creds_written(result) + + +def check_admin_cli_isolated(result: PreflightResult) -> None: + admin_home = os.environ.get("POLYCLAW_ADMIN_HOME", "/admin-home") + azure_dir = Path(admin_home) / ".azure" + mode = cfg.server_mode.value + + if mode == "admin": + exists = azure_dir.exists() + _add( + result, id="secret_admin_cli_isolated", category="secrets", + name="Admin CLI Session Isolated", + status="pass" if exists else "warn", + detail=( + f"Azure CLI config at {azure_dir}: " + f"{'present' if exists else 'not found'}" + ), + evidence=( + f"HOME={os.environ.get('HOME', '?')}\n" + f"AZURE_CONFIG_DIR={os.environ.get('AZURE_CONFIG_DIR', '?')}\n" + f"exists={exists}" + ), + command=f"os.path.exists({azure_dir})", + ) + elif mode == "runtime": + exists = azure_dir.exists() + _add( + result, id="secret_admin_cli_isolated", category="secrets", + name="Admin CLI Session Isolated", + status="pass" if not exists else "fail", + detail=( + "Admin CLI config not accessible from runtime" + if not exists + else f"RISK: Admin CLI config accessible at {azure_dir}" + ), + evidence=( + f"HOME={os.environ.get('HOME', '?')}\n" + f"{azure_dir} exists={exists}" + ), + command=f"os.path.exists({azure_dir})", + ) + else: + _add( + result, id="secret_admin_cli_isolated", category="secrets", + name="Admin CLI Session Isolated", + status="warn", + detail=( + "Combined mode -- admin and runtime share the same " + "container (no credential isolation)" + ), + evidence=f"POLYCLAW_SERVER_MODE={mode}", + command="cfg.server_mode", + ) + + +def check_no_github_in_runtime(result: PreflightResult) -> None: + env_data = cfg.env.read_all() + gh_token = env_data.get("GITHUB_TOKEN", "") + gh2 = env_data.get("GH_TOKEN", "") + mode = cfg.server_mode.value + + if mode == "runtime": + has = bool(gh_token or gh2) + _add( + result, id="secret_no_github_runtime", category="secrets", + name="No GitHub Token in Runtime", + status="fail" if has else "pass", + detail=( + "GitHub token NOT present in runtime environment" + if not has + else "RISK: GitHub token accessible in runtime env" + ), + evidence=( + f"GITHUB_TOKEN={'set (' + str(len(gh_token)) + ' chars)' if gh_token else 'empty'}\n" + f"GH_TOKEN={'set' if gh2 else 'empty'}" + ), + command="env: GITHUB_TOKEN, GH_TOKEN", + ) + elif mode == "admin": + has = bool(gh_token or gh2) + _add( + result, id="secret_no_github_runtime", category="secrets", + name="GitHub Token (Admin Only)", + status="pass", + detail=f"GitHub token on admin: {'present' if has else 'not configured'}", + evidence=( + f"GITHUB_TOKEN={'set' if gh_token else 'empty'}\n" + f"GH_TOKEN={'set' if gh2 else 'empty'}" + ), + command="env: GITHUB_TOKEN, GH_TOKEN", + ) + else: + _add( + result, id="secret_no_github_runtime", category="secrets", + name="GitHub Token Isolation", + status="warn", + detail="Combined mode -- GitHub token shared with agent runtime", + evidence=f"POLYCLAW_SERVER_MODE={mode}", + command="cfg.server_mode + env", + ) + + +def check_bot_credentials(result: PreflightResult) -> None: + env_data = cfg.env.read_all() + app_id = env_data.get("BOT_APP_ID", "") + app_pw = env_data.get("BOT_APP_PASSWORD", "") + both = bool(app_id and app_pw) + + _add( + result, id="secret_bot_creds", category="secrets", + name="Bot Credentials Present", + status="pass" if both else ("warn" if app_id else "skip"), + detail=( + f"BOT_APP_ID={'set' if app_id else 'missing'}, " + f"BOT_APP_PASSWORD={'set' if app_pw else 'missing'}" + ), + evidence=( + f"BOT_APP_ID={app_id[:12] + '...' if app_id else '(empty)'}\n" + f"BOT_APP_PASSWORD={'***' if app_pw else '(empty)'}" + ), + command="env: BOT_APP_ID, BOT_APP_PASSWORD", + ) + + +def check_admin_secret(result: PreflightResult) -> None: + secret = cfg.admin_secret + _add( + result, id="secret_admin_secret", category="secrets", + name="Admin Secret Configured", + status="pass" if secret else "fail", + detail=( + f"ADMIN_SECRET set ({len(secret)} chars)" + if secret + else "ADMIN_SECRET MISSING" + ), + evidence=f"ADMIN_SECRET={'***' if secret else '(empty)'}\nlength={len(secret) if secret else 0}", + command="env: ADMIN_SECRET", + ) + + +def check_kv_reachable(result: PreflightResult) -> None: + from ..keyvault import kv as _kv + + if not _kv.enabled: + _add( + result, id="secret_kv_reachable", category="secrets", + name="Key Vault Reachable", + status="skip", + detail="Key Vault not configured", + evidence=f"KEY_VAULT_URL={cfg.env.read('KEY_VAULT_URL') or '(empty)'}", + command="keyvault.enabled", + ) + return + + try: + secrets_list = _kv.list_secrets() + _add( + result, id="secret_kv_reachable", category="secrets", + name="Key Vault Reachable", + status="pass", + detail=f"Key Vault accessible, {len(secrets_list)} secret(s) readable", + evidence=f"url={_kv.url}\nsecrets_count={len(secrets_list)}", + command="keyvault.list_secrets()", + ) + except Exception as exc: + _add( + result, id="secret_kv_reachable", category="secrets", + name="Key Vault Reachable", + status="fail", + detail=f"Key Vault NOT reachable: {exc}", + evidence=f"url={_kv.url}\nerror={exc}", + command="keyvault.list_secrets()", + ) + + +def check_acs_credential(result: PreflightResult) -> None: + conn = cfg.acs_connection_string + if conn: + parts = { + k.strip().lower(): v.strip() + for k, _, v in (seg.partition("=") for seg in conn.split(";") if "=" in seg) + } + has_ep = bool(parts.get("endpoint")) + _add( + result, id="secret_acs_present", category="secrets", + name="ACS Connection String", + status="pass" if has_ep else "warn", + detail=( + f"ACS connection string " + f"{'well-formed' if has_ep else 'malformed (missing endpoint)'}" + ), + evidence=f"ACS_CONNECTION_STRING=***({len(conn)} chars)\nhas_endpoint={has_ep}", + command="env: ACS_CONNECTION_STRING", + ) + else: + _add( + result, id="secret_acs_present", category="secrets", + name="ACS Connection String", + status="skip", + detail="ACS not configured", + evidence="ACS_CONNECTION_STRING=(empty)", + command="env: ACS_CONNECTION_STRING", + ) + + +def check_aoai_credential(result: PreflightResult) -> None: + endpoint = cfg.azure_openai_endpoint + key = cfg.azure_openai_api_key + + if endpoint: + _add( + result, id="secret_aoai_present", category="secrets", + name="Azure OpenAI Configuration", + status="pass", + detail=f"Endpoint configured, {'API key' if key else 'identity auth'} mode", + evidence=( + f"AZURE_OPENAI_ENDPOINT={endpoint}\n" + f"AZURE_OPENAI_API_KEY={'***' if key else '(identity-auth)'}" + ), + command="env: AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY", + ) + else: + _add( + result, id="secret_aoai_present", category="secrets", + name="Azure OpenAI Configuration", + status="skip", + detail="Azure OpenAI not configured", + evidence="AZURE_OPENAI_ENDPOINT=(empty)", + command="env: AZURE_OPENAI_ENDPOINT", + ) + + +def check_sp_creds_written(result: PreflightResult) -> None: + env_data = cfg.env.read_all() + sp_id = env_data.get("RUNTIME_SP_APP_ID", "") + sp_pw = env_data.get("RUNTIME_SP_PASSWORD", "") + sp_tenant = env_data.get("RUNTIME_SP_TENANT", "") + + if not sp_id: + mi_id = env_data.get("ACA_MI_CLIENT_ID", "") + if mi_id: + _add( + result, id="secret_identity_creds", category="secrets", + name="Runtime Identity Credentials in .env", + status="pass", + detail="Managed identity credentials written to .env", + evidence=( + f"ACA_MI_CLIENT_ID={mi_id}\n" + f"ACA_MI_RESOURCE_ID={env_data.get('ACA_MI_RESOURCE_ID', '?')}" + ), + command="env: ACA_MI_CLIENT_ID, ACA_MI_RESOURCE_ID", + ) + else: + _add( + result, id="secret_identity_creds", category="secrets", + name="Runtime Identity Credentials in .env", + status="skip", + detail="No runtime identity credentials in .env", + evidence="RUNTIME_SP_APP_ID=(empty)\nACA_MI_CLIENT_ID=(empty)", + command="env: RUNTIME_SP_APP_ID, ACA_MI_CLIENT_ID", + ) + return + + all_set = bool(sp_id and sp_pw and sp_tenant) + _add( + result, id="secret_identity_creds", category="secrets", + name="SP Credentials in .env", + status="pass" if all_set else "fail", + detail=( + f"app_id={'set' if sp_id else 'MISSING'}, " + f"password={'set' if sp_pw else 'MISSING'}, " + f"tenant={'set' if sp_tenant else 'MISSING'}" + ), + evidence=( + f"RUNTIME_SP_APP_ID={sp_id[:12] + '...' if sp_id else '(empty)'}\n" + f"RUNTIME_SP_PASSWORD={'***' if sp_pw else '(empty)'}\n" + f"RUNTIME_SP_TENANT={sp_tenant or '(empty)'}" + ), + command="env: RUNTIME_SP_APP_ID, RUNTIME_SP_PASSWORD, RUNTIME_SP_TENANT", + ) diff --git a/app/runtime/services/prompt_shield.py b/app/runtime/services/security/prompt_shield.py similarity index 99% rename from app/runtime/services/prompt_shield.py rename to app/runtime/services/security/prompt_shield.py index 8bbc1bd..b5494e7 100644 --- a/app/runtime/services/prompt_shield.py +++ b/app/runtime/services/security/prompt_shield.py @@ -234,8 +234,6 @@ def __init__(self) -> None: def get_token(self) -> str: """Return a valid bearer token, refreshing if necessary.""" - import time - # Return cached token if still valid (with 5-min buffer) if self._cached_token and time.time() < self._expires_on - 300: return self._cached_token diff --git a/app/runtime/services/security/security_preflight.py b/app/runtime/services/security/security_preflight.py new file mode 100644 index 0000000..be6e82b --- /dev/null +++ b/app/runtime/services/security/security_preflight.py @@ -0,0 +1,154 @@ +"""Security preflight checker -- verifiable runtime identity and secret isolation checks. + +Every check runs a real command or environment inspection and reports evidence. +No static claims -- every assertion is verified at runtime. + +Identity and RBAC checks live in ``preflight_identity``. +Secret-isolation checks live in ``preflight_secrets``. +""" + +from __future__ import annotations + +import logging +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from typing import Any + +from ...config.settings import cfg +from ..cloud.azure import AzureCLI + +logger = logging.getLogger(__name__) + +# Elevated RBAC roles that the runtime identity should never hold. +_ELEVATED_ROLES = frozenset({ + "Owner", + "Contributor", + "User Access Administrator", + "Role Based Access Control Administrator", +}) + +# Type alias for the identity dict passed between preflight check modules. +IdentityInfo = dict[str, Any] + + +@dataclass +class PreflightCheck: + """Result of a single security preflight check.""" + + id: str + category: str + name: str + status: str = "pending" # pending | pass | fail | warn | skip + detail: str = "" + evidence: str = "" + command: str = "" + + +@dataclass +class PreflightResult: + """Aggregated result of all preflight checks.""" + + checks: list[PreflightCheck] = field(default_factory=list) + run_at: str = "" + passed: int = 0 + failed: int = 0 + warnings: int = 0 + skipped: int = 0 + + +def add_check(result: PreflightResult, **kwargs: Any) -> PreflightCheck: + """Create a :class:`PreflightCheck`, append it to *result*, and return it.""" + check = PreflightCheck(**kwargs) + result.checks.append(check) + return check + + +class SecurityPreflightChecker: + """Run verifiable security checks against the runtime identity and secrets.""" + + def __init__(self, az: AzureCLI) -> None: + self._az = az + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def run_all(self) -> PreflightResult: + """Execute all security preflight checks and return evidence.""" + from . import preflight_identity as _id + from . import preflight_rbac as _rbac + from . import preflight_secrets as _sec + + result = PreflightResult(run_at=datetime.now(timezone.utc).isoformat()) + + # Gate: is Azure CLI logged in? + if not _id.check_azure_logged_in(self._az, result): + _id.skip_azure_checks(result) + _sec.run_secret_checks(result) + self._tally(result) + return result + + # Identity verification + identity = _id.check_identity_configured(self._az, result) + if identity: + _id.check_identity_valid(self._az, result, identity) + _id.check_credential_expiry(self._az, result, identity) + + # RBAC verification + assignments = _rbac.check_rbac_list(self._az, result, identity) + if assignments is not None: + bot_rg = cfg.env.read("BOT_RESOURCE_GROUP") or "" + _rbac.check_rbac_has_role( + result, assignments, "Azure Bot Service Contributor Role", + "rbac_bot_contributor", "Azure Bot Service Contributor Role", bot_rg, + ) + _rbac.check_rbac_has_role( + result, assignments, "Reader", + "rbac_reader", "Reader Role", bot_rg, + ) + _rbac.check_rbac_kv_access(result, assignments, identity) + if identity.get("strategy") == "managed_identity": + _rbac.check_rbac_has_role( + result, assignments, "Cognitive Services OpenAI User", + "rbac_aoai_user", "Azure OpenAI Access", + "", + missing_severity="warn", + missing_detail="Needed for identity-auth voice", + ) + _rbac.check_rbac_session_pool(result, assignments) + _rbac.check_rbac_no_elevated(result, assignments) + _rbac.check_rbac_scope_contained(result, assignments) + + # Secret isolation + _sec.run_secret_checks(result) + + self._tally(result) + return result + + @staticmethod + def to_dict(result: PreflightResult) -> dict[str, Any]: + """Serialize a *PreflightResult* to a JSON-safe dict.""" + return { + "checks": [asdict(c) for c in result.checks], + "run_at": result.run_at, + "passed": result.passed, + "failed": result.failed, + "warnings": result.warnings, + "skipped": result.skipped, + } + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _tally(result: PreflightResult) -> None: + for c in result.checks: + if c.status == "pass": + result.passed += 1 + elif c.status == "fail": + result.failed += 1 + elif c.status == "warn": + result.warnings += 1 + elif c.status == "skip": + result.skipped += 1 diff --git a/app/runtime/services/security_preflight.py b/app/runtime/services/security_preflight.py deleted file mode 100644 index 643d902..0000000 --- a/app/runtime/services/security_preflight.py +++ /dev/null @@ -1,918 +0,0 @@ -"""Security preflight checker -- verifiable runtime identity and secret isolation checks. - -Every check runs a real command or environment inspection and reports evidence. -No static claims -- every assertion is verified at runtime. -""" - -from __future__ import annotations - -import logging -import os -from dataclasses import asdict, dataclass, field -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -from ..config.settings import cfg -from .azure import AzureCLI - -logger = logging.getLogger(__name__) - -# Elevated RBAC roles that the runtime identity should never hold. -_ELEVATED_ROLES = frozenset({ - "Owner", - "Contributor", - "User Access Administrator", - "Role Based Access Control Administrator", -}) - - -@dataclass -class PreflightCheck: - """Result of a single security preflight check.""" - - id: str - category: str - name: str - status: str = "pending" # pending | pass | fail | warn | skip - detail: str = "" - evidence: str = "" - command: str = "" - - -@dataclass -class PreflightResult: - """Aggregated result of all preflight checks.""" - - checks: list[PreflightCheck] = field(default_factory=list) - run_at: str = "" - passed: int = 0 - failed: int = 0 - warnings: int = 0 - skipped: int = 0 - - -class SecurityPreflightChecker: - """Run verifiable security checks against the runtime identity and secrets.""" - - def __init__(self, az: AzureCLI) -> None: - self._az = az - - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ - - def run_all(self) -> PreflightResult: - """Execute all security preflight checks and return evidence.""" - result = PreflightResult(run_at=datetime.now(timezone.utc).isoformat()) - - # Gate: is Azure CLI logged in? - if not self._check_azure_logged_in(result): - self._skip_azure_checks(result) - self._run_secret_checks(result) - self._tally(result) - return result - - # Identity verification - identity = self._check_identity_configured(result) - if identity: - self._check_identity_valid(result, identity) - self._check_credential_expiry(result, identity) - - # RBAC verification - assignments = self._check_rbac_list(result, identity) - if assignments is not None: - bot_rg = cfg.env.read("BOT_RESOURCE_GROUP") or "" - self._check_rbac_has_role( - result, assignments, "Azure Bot Service Contributor Role", - "rbac_bot_contributor", "Azure Bot Service Contributor Role", bot_rg, - ) - self._check_rbac_has_role( - result, assignments, "Reader", - "rbac_reader", "Reader Role", bot_rg, - ) - self._check_rbac_kv_access(result, assignments, identity) - if identity.get("strategy") == "managed_identity": - self._check_rbac_has_role( - result, assignments, "Cognitive Services OpenAI User", - "rbac_aoai_user", "Azure OpenAI Access", - "", - missing_severity="warn", - missing_detail="Needed for identity-auth voice", - ) - self._check_rbac_session_pool(result, assignments) - self._check_rbac_no_elevated(result, assignments) - self._check_rbac_scope_contained(result, assignments) - - # Secret isolation - self._run_secret_checks(result) - - self._tally(result) - return result - - @staticmethod - def to_dict(result: PreflightResult) -> dict[str, Any]: - """Serialize a *PreflightResult* to a JSON-safe dict.""" - return { - "checks": [asdict(c) for c in result.checks], - "run_at": result.run_at, - "passed": result.passed, - "failed": result.failed, - "warnings": result.warnings, - "skipped": result.skipped, - } - - # ------------------------------------------------------------------ - # Helpers - # ------------------------------------------------------------------ - - @staticmethod - def _tally(result: PreflightResult) -> None: - for c in result.checks: - if c.status == "pass": - result.passed += 1 - elif c.status == "fail": - result.failed += 1 - elif c.status == "warn": - result.warnings += 1 - elif c.status == "skip": - result.skipped += 1 - - def _add(self, result: PreflightResult, **kwargs: Any) -> PreflightCheck: - check = PreflightCheck(**kwargs) - result.checks.append(check) - return check - - # ------------------------------------------------------------------ - # Azure login gate - # ------------------------------------------------------------------ - - def _check_azure_logged_in(self, result: PreflightResult) -> bool: - cmd = "az account show" - account = self._az.json("account", "show", quiet=True) - if isinstance(account, dict) and account.get("id"): - sub = account.get("name", account.get("id", "?")) - self._add( - result, id="azure_logged_in", category="identity", - name="Azure CLI Authenticated", - status="pass", - detail=f"Logged in to subscription: {sub}", - evidence=f"subscription={sub}\ntenantId={account.get('tenantId', '?')}", - command=cmd, - ) - return True - self._add( - result, id="azure_logged_in", category="identity", - name="Azure CLI Authenticated", - status="fail", - detail="Not logged in -- RBAC and identity checks require Azure CLI auth", - evidence=self._az.last_stderr or "No response", - command=cmd, - ) - return False - - def _skip_azure_checks(self, result: PreflightResult) -> None: - for check_id, name, cat in [ - ("identity_configured", "Runtime Identity Configured", "identity"), - ("identity_valid", "Identity Exists in Azure AD", "identity"), - ("identity_credential_expiry", "Credential Expiry", "identity"), - ("rbac_assignments_list", "RBAC Assignments", "rbac"), - ("rbac_bot_contributor", "Azure Bot Service Contributor Role", "rbac"), - ("rbac_reader", "Reader Role", "rbac"), - ("rbac_kv_access", "Key Vault Access Role", "rbac"), - ("rbac_session_pool", "Session Pool Executor", "rbac"), - ("rbac_no_elevated", "No Elevated Roles", "rbac"), - ("rbac_scope_contained", "Scope Limited to Resource Group", "rbac"), - ]: - self._add( - result, id=check_id, category=cat, name=name, - status="skip", - detail="Skipped -- Azure CLI not authenticated", - command="", - ) - - # ------------------------------------------------------------------ - # Identity checks - # ------------------------------------------------------------------ - - def _check_identity_configured( - self, result: PreflightResult, - ) -> dict[str, Any] | None: - sp_app_id = cfg.env.read("RUNTIME_SP_APP_ID") - mi_client_id = cfg.env.read("ACA_MI_CLIENT_ID") - mi_resource_id = cfg.env.read("ACA_MI_RESOURCE_ID") - - if mi_client_id: - self._add( - result, id="identity_configured", category="identity", - name="Runtime Identity Configured", - status="pass", - detail=f"User-assigned managed identity: client_id={mi_client_id}", - evidence=( - f"ACA_MI_CLIENT_ID={mi_client_id}\n" - f"ACA_MI_RESOURCE_ID={mi_resource_id}" - ), - command="env: ACA_MI_CLIENT_ID, ACA_MI_RESOURCE_ID", - ) - return { - "strategy": "managed_identity", - "client_id": mi_client_id, - "resource_id": mi_resource_id, - "assignee": mi_client_id, - } - - if sp_app_id: - sp_tenant = cfg.env.read("RUNTIME_SP_TENANT") - has_pw = bool(cfg.env.read("RUNTIME_SP_PASSWORD")) - self._add( - result, id="identity_configured", category="identity", - name="Runtime Identity Configured", - status="pass", - detail=f"Scoped service principal: app_id={sp_app_id}", - evidence=( - f"RUNTIME_SP_APP_ID={sp_app_id}\n" - f"RUNTIME_SP_TENANT={sp_tenant}\n" - f"RUNTIME_SP_PASSWORD={'***' if has_pw else 'MISSING'}" - ), - command="env: RUNTIME_SP_APP_ID, RUNTIME_SP_TENANT, RUNTIME_SP_PASSWORD", - ) - return { - "strategy": "sp", - "app_id": sp_app_id, - "tenant": sp_tenant, - "assignee": sp_app_id, - } - - self._add( - result, id="identity_configured", category="identity", - name="Runtime Identity Configured", - status="skip", - detail="No runtime identity configured (RUNTIME_SP_* and ACA_MI_* absent)", - evidence="RUNTIME_SP_APP_ID=(empty)\nACA_MI_CLIENT_ID=(empty)", - command="env: RUNTIME_SP_APP_ID, ACA_MI_CLIENT_ID", - ) - return None - - def _check_identity_valid( - self, result: PreflightResult, info: dict[str, Any], - ) -> None: - if info["strategy"] == "sp": - app_id = info["app_id"] - cmd = f"az ad sp show --id {app_id}" - sp = self._az.json("ad", "sp", "show", "--id", app_id) - if isinstance(sp, dict) and sp.get("appId"): - display = sp.get("displayName", "?") - self._add( - result, id="identity_valid", category="identity", - name="Service Principal Exists in Azure AD", - status="pass", - detail=f"{display} ({app_id})", - evidence=( - f"displayName={display}\n" - f"appId={app_id}\n" - f"objectId={sp.get('id', '?')}" - ), - command=cmd, - ) - else: - self._add( - result, id="identity_valid", category="identity", - name="Service Principal Exists in Azure AD", - status="fail", - detail=f"SP not found: {app_id}", - evidence=self._az.last_stderr or "No response", - command=cmd, - ) - else: - resource_id = info.get("resource_id", "") - if not resource_id: - self._add( - result, id="identity_valid", category="identity", - name="Managed Identity Exists", - status="skip", detail="No MI resource ID configured", - command="", - ) - return - cmd = f"az identity show --ids {resource_id}" - mi = self._az.json("identity", "show", "--ids", resource_id) - if isinstance(mi, dict) and mi.get("clientId"): - self._add( - result, id="identity_valid", category="identity", - name="Managed Identity Exists", - status="pass", - detail=f"{mi.get('name', '?')} (client={mi.get('clientId', '?')})", - evidence=( - f"name={mi.get('name', '?')}\n" - f"clientId={mi.get('clientId', '?')}\n" - f"principalId={mi.get('principalId', '?')}" - ), - command=cmd, - ) - else: - self._add( - result, id="identity_valid", category="identity", - name="Managed Identity Exists", - status="fail", - detail=f"MI not found: {resource_id}", - evidence=self._az.last_stderr or "No response", - command=cmd, - ) - - def _check_credential_expiry( - self, result: PreflightResult, info: dict[str, Any], - ) -> None: - if info["strategy"] != "sp": - self._add( - result, id="identity_credential_expiry", category="identity", - name="Credential Expiry", - status="pass", - detail="Managed identities do not have expiring credentials", - command="(not applicable for MI)", - ) - return - - app_id = info["app_id"] - cmd = f"az ad app credential list --id {app_id}" - creds = self._az.json("ad", "app", "credential", "list", "--id", app_id) - if not isinstance(creds, list) or not creds: - self._add( - result, id="identity_credential_expiry", category="identity", - name="Credential Expiry", - status="warn", - detail="Could not retrieve credential list", - evidence=self._az.last_stderr or "Empty response", - command=cmd, - ) - return - - latest = max(creds, key=lambda c: c.get("endDateTime", "")) - end = latest.get("endDateTime", "") - now = datetime.now(timezone.utc).isoformat() - - if end and end > now: - self._add( - result, id="identity_credential_expiry", category="identity", - name="Credential Expiry", - status="pass", - detail=f"Valid until {end}", - evidence=f"endDateTime={end}\nnow={now}\ncredentials_count={len(creds)}", - command=cmd, - ) - else: - self._add( - result, id="identity_credential_expiry", category="identity", - name="Credential Expiry", - status="fail", - detail=f"Credential EXPIRED: {end}", - evidence=f"endDateTime={end}\nnow={now}", - command=cmd, - ) - - # ------------------------------------------------------------------ - # RBAC checks - # ------------------------------------------------------------------ - - def _check_rbac_list( - self, result: PreflightResult, info: dict[str, Any], - ) -> list[dict[str, Any]] | None: - assignee = info.get("assignee", "") - if not assignee: - return None - - cmd = f"az role assignment list --assignee {assignee} --all" - assignments = self._az.json( - "role", "assignment", "list", "--assignee", assignee, "--all", - ) - if not isinstance(assignments, list): - self._add( - result, id="rbac_assignments_list", category="rbac", - name="RBAC Assignments Retrieved", - status="fail", - detail="Could not list RBAC assignments", - evidence=self._az.last_stderr or "No response", - command=cmd, - ) - return None - - summary = ", ".join( - f"{a.get('roleDefinitionName', '?')} @ " - f"{a.get('scope', '?').rsplit('/', 1)[-1]}" - for a in assignments - ) - self._add( - result, id="rbac_assignments_list", category="rbac", - name="RBAC Assignments Retrieved", - status="pass", - detail=f"{len(assignments)} assignment(s): {summary}", - evidence="\n".join( - f"- {a.get('roleDefinitionName', '?')} on {a.get('scope', '?')}" - for a in assignments - ), - command=cmd, - ) - return assignments - - def _check_rbac_has_role( - self, - result: PreflightResult, - assignments: list[dict[str, Any]], - role_name: str, - check_id: str, - check_name: str, - bot_rg: str, - *, - missing_severity: str = "fail", - missing_detail: str = "", - ) -> None: - matching = [ - a for a in assignments - if a.get("roleDefinitionName") == role_name - ] - if matching: - scopes = [a.get("scope", "") for a in matching] - self._add( - result, id=check_id, category="rbac", - name=check_name, - status="pass", - detail=f"{role_name} assigned ({len(matching)} assignment(s))", - evidence="\n".join(f"scope={s}" for s in scopes), - command=f"Filtered from role assignment list", - ) - else: - detail = missing_detail or f"{role_name} NOT found in assignments" - self._add( - result, id=check_id, category="rbac", - name=check_name, - status=missing_severity, - detail=detail, - evidence=( - f"Expected '{role_name}' but not present " - f"in {len(assignments)} assignment(s)" - ), - command=f"Filtered from role assignment list", - ) - - def _check_rbac_kv_access( - self, - result: PreflightResult, - assignments: list[dict[str, Any]], - info: dict[str, Any], - ) -> None: - kv_roles = [ - a for a in assignments - if "key vault" in (a.get("roleDefinitionName") or "").lower() - ] - - if not kv_roles: - self._add( - result, id="rbac_kv_access", category="rbac", - name="Key Vault Access Role", - status="warn", - detail="No Key Vault role assignment found", - evidence=f"Checked {len(assignments)} assignments for 'Key Vault' roles", - command="Filtered from role assignment list", - ) - return - - role_names = [a.get("roleDefinitionName", "?") for a in kv_roles] - has_officer = "Key Vault Secrets Officer" in role_names - has_user = "Key Vault Secrets User" in role_names - - if info["strategy"] == "managed_identity": - if has_user and not has_officer: - status = "pass" - detail = "Key Vault Secrets User (read-only) -- correct for MI" - elif has_officer: - status = "warn" - detail = ( - "Key Vault Secrets Officer (read+write) -- " - "consider restricting to Secrets User for runtime" - ) - else: - status = "pass" - detail = f"Key Vault role: {', '.join(role_names)}" - else: - # SP may legitimately have Officer (used during provisioning) - status = "pass" - detail = f"Key Vault role: {', '.join(role_names)}" - - self._add( - result, id="rbac_kv_access", category="rbac", - name="Key Vault Access Role", - status=status, - detail=detail, - evidence="\n".join( - f"- {a.get('roleDefinitionName', '?')} on {a.get('scope', '?')}" - for a in kv_roles - ), - command="Filtered from role assignment list", - ) - - def _check_rbac_session_pool( - self, result: PreflightResult, assignments: list[dict[str, Any]], - ) -> None: - from ..state.sandbox_config import SandboxConfigStore - - try: - sandbox_store = SandboxConfigStore() - sandbox_enabled = sandbox_store.enabled - sandbox_configured = sandbox_store.is_provisioned - except Exception: - sandbox_enabled = False - sandbox_configured = False - - matching = [ - a for a in assignments - if "session" in (a.get("roleDefinitionName") or "").lower() - ] - if matching: - names = [a.get("roleDefinitionName", "?") for a in matching] - self._add( - result, id="rbac_session_pool", category="rbac", - name="Session Pool Executor", - status="pass", - detail=f"Session role: {', '.join(names)}", - evidence="\n".join( - f"scope={a.get('scope', '?')}" for a in matching - ), - command="Filtered from role assignment list", - ) - elif sandbox_enabled or sandbox_configured: - self._add( - result, id="rbac_session_pool", category="rbac", - name="Session Pool Executor", - status="fail", - detail=( - "Azure ContainerApps Session Executor NOT found -- " - "required for sandbox (HTTP 403 on file upload/execute)" - ), - evidence=f"Not present in {len(assignments)} assignment(s)", - command="Filtered from role assignment list", - ) - else: - self._add( - result, id="rbac_session_pool", category="rbac", - name="Session Pool Executor", - status="warn", - detail="ContainerApps Session Executor NOT found (needed if sandbox is enabled)", - evidence=f"Not present in {len(assignments)} assignment(s)", - command="Filtered from role assignment list", - ) - - def _check_rbac_no_elevated( - self, result: PreflightResult, assignments: list[dict[str, Any]], - ) -> None: - elevated = [ - a for a in assignments - if a.get("roleDefinitionName") in _ELEVATED_ROLES - ] - if not elevated: - self._add( - result, id="rbac_no_elevated", category="rbac", - name="No Elevated Roles", - status="pass", - detail="No Owner, Contributor, or User Access Administrator roles", - evidence=( - f"Checked {len(assignments)} assignment(s) against: " - f"{', '.join(sorted(_ELEVATED_ROLES))}" - ), - command="Filtered from role assignment list", - ) - else: - self._add( - result, id="rbac_no_elevated", category="rbac", - name="No Elevated Roles", - status="fail", - detail=( - f"ELEVATED roles found: " - f"{', '.join(a.get('roleDefinitionName', '?') for a in elevated)}" - ), - evidence="\n".join( - f"- {a.get('roleDefinitionName', '?')} on {a.get('scope', '?')}" - for a in elevated - ), - command="Filtered from role assignment list", - ) - - def _check_rbac_scope_contained( - self, result: PreflightResult, assignments: list[dict[str, Any]], - ) -> None: - out_of_scope = [ - a for a in assignments - if "/resourcegroups/" not in (a.get("scope") or "").lower() - ] - if not out_of_scope: - self._add( - result, id="rbac_scope_contained", category="rbac", - name="Scope Limited to Resource Group", - status="pass", - detail=( - f"All {len(assignments)} assignment(s) scoped to " - f"resource group level or below" - ), - evidence="\n".join( - f"- {a.get('scope', '?')}" for a in assignments - ) if assignments else "No assignments", - command="Scope analysis from role assignment list", - ) - else: - self._add( - result, id="rbac_scope_contained", category="rbac", - name="Scope Limited to Resource Group", - status="fail", - detail=( - f"{len(out_of_scope)} assignment(s) at subscription or management " - f"group level" - ), - evidence="\n".join( - f"- {a.get('roleDefinitionName', '?')} at {a.get('scope', '?')}" - for a in out_of_scope - ), - command="Scope analysis from role assignment list", - ) - - # ------------------------------------------------------------------ - # Secret isolation checks - # ------------------------------------------------------------------ - - def _run_secret_checks(self, result: PreflightResult) -> None: - self._check_admin_cli_isolated(result) - self._check_no_github_in_runtime(result) - self._check_bot_credentials(result) - self._check_admin_secret(result) - self._check_kv_reachable(result) - self._check_acs_credential(result) - self._check_aoai_credential(result) - self._check_sp_creds_written(result) - - def _check_admin_cli_isolated(self, result: PreflightResult) -> None: - admin_home = os.environ.get("POLYCLAW_ADMIN_HOME", "/admin-home") - azure_dir = Path(admin_home) / ".azure" - mode = cfg.server_mode.value - - if mode == "admin": - exists = azure_dir.exists() - self._add( - result, id="secret_admin_cli_isolated", category="secrets", - name="Admin CLI Session Isolated", - status="pass" if exists else "warn", - detail=( - f"Azure CLI config at {azure_dir}: " - f"{'present' if exists else 'not found'}" - ), - evidence=( - f"HOME={os.environ.get('HOME', '?')}\n" - f"AZURE_CONFIG_DIR={os.environ.get('AZURE_CONFIG_DIR', '?')}\n" - f"exists={exists}" - ), - command=f"os.path.exists({azure_dir})", - ) - elif mode == "runtime": - exists = azure_dir.exists() - self._add( - result, id="secret_admin_cli_isolated", category="secrets", - name="Admin CLI Session Isolated", - status="pass" if not exists else "fail", - detail=( - "Admin CLI config not accessible from runtime" - if not exists - else f"RISK: Admin CLI config accessible at {azure_dir}" - ), - evidence=( - f"HOME={os.environ.get('HOME', '?')}\n" - f"{azure_dir} exists={exists}" - ), - command=f"os.path.exists({azure_dir})", - ) - else: - self._add( - result, id="secret_admin_cli_isolated", category="secrets", - name="Admin CLI Session Isolated", - status="warn", - detail=( - "Combined mode -- admin and runtime share the same " - "container (no credential isolation)" - ), - evidence=f"POLYCLAW_SERVER_MODE={mode}", - command="cfg.server_mode", - ) - - def _check_no_github_in_runtime(self, result: PreflightResult) -> None: - env_data = cfg.env.read_all() - gh_token = env_data.get("GITHUB_TOKEN", "") - gh2 = env_data.get("GH_TOKEN", "") - mode = cfg.server_mode.value - - if mode == "runtime": - has = bool(gh_token or gh2) - self._add( - result, id="secret_no_github_runtime", category="secrets", - name="No GitHub Token in Runtime", - status="fail" if has else "pass", - detail=( - "GitHub token NOT present in runtime environment" - if not has - else "RISK: GitHub token accessible in runtime env" - ), - evidence=( - f"GITHUB_TOKEN={'set (' + str(len(gh_token)) + ' chars)' if gh_token else 'empty'}\n" - f"GH_TOKEN={'set' if gh2 else 'empty'}" - ), - command="env: GITHUB_TOKEN, GH_TOKEN", - ) - elif mode == "admin": - has = bool(gh_token or gh2) - self._add( - result, id="secret_no_github_runtime", category="secrets", - name="GitHub Token (Admin Only)", - status="pass", - detail=f"GitHub token on admin: {'present' if has else 'not configured'}", - evidence=( - f"GITHUB_TOKEN={'set' if gh_token else 'empty'}\n" - f"GH_TOKEN={'set' if gh2 else 'empty'}" - ), - command="env: GITHUB_TOKEN, GH_TOKEN", - ) - else: - self._add( - result, id="secret_no_github_runtime", category="secrets", - name="GitHub Token Isolation", - status="warn", - detail="Combined mode -- GitHub token shared with agent runtime", - evidence=f"POLYCLAW_SERVER_MODE={mode}", - command="cfg.server_mode + env", - ) - - def _check_bot_credentials(self, result: PreflightResult) -> None: - env_data = cfg.env.read_all() - app_id = env_data.get("BOT_APP_ID", "") - app_pw = env_data.get("BOT_APP_PASSWORD", "") - both = bool(app_id and app_pw) - - self._add( - result, id="secret_bot_creds", category="secrets", - name="Bot Credentials Present", - status="pass" if both else ("warn" if app_id else "skip"), - detail=( - f"BOT_APP_ID={'set' if app_id else 'missing'}, " - f"BOT_APP_PASSWORD={'set' if app_pw else 'missing'}" - ), - evidence=( - f"BOT_APP_ID={app_id[:12] + '...' if app_id else '(empty)'}\n" - f"BOT_APP_PASSWORD={'***' if app_pw else '(empty)'}" - ), - command="env: BOT_APP_ID, BOT_APP_PASSWORD", - ) - - def _check_admin_secret(self, result: PreflightResult) -> None: - secret = cfg.admin_secret - self._add( - result, id="secret_admin_secret", category="secrets", - name="Admin Secret Configured", - status="pass" if secret else "fail", - detail=( - f"ADMIN_SECRET set ({len(secret)} chars)" - if secret - else "ADMIN_SECRET MISSING" - ), - evidence=f"ADMIN_SECRET={'***' if secret else '(empty)'}\nlength={len(secret) if secret else 0}", - command="env: ADMIN_SECRET", - ) - - def _check_kv_reachable(self, result: PreflightResult) -> None: - from ..services.keyvault import kv as _kv - - if not _kv.enabled: - self._add( - result, id="secret_kv_reachable", category="secrets", - name="Key Vault Reachable", - status="skip", - detail="Key Vault not configured", - evidence=f"KEY_VAULT_URL={cfg.env.read('KEY_VAULT_URL') or '(empty)'}", - command="keyvault.enabled", - ) - return - - try: - secrets_list = _kv.list_secrets() - self._add( - result, id="secret_kv_reachable", category="secrets", - name="Key Vault Reachable", - status="pass", - detail=f"Key Vault accessible, {len(secrets_list)} secret(s) readable", - evidence=f"url={_kv.url}\nsecrets_count={len(secrets_list)}", - command="keyvault.list_secrets()", - ) - except Exception as exc: - self._add( - result, id="secret_kv_reachable", category="secrets", - name="Key Vault Reachable", - status="fail", - detail=f"Key Vault NOT reachable: {exc}", - evidence=f"url={_kv.url}\nerror={exc}", - command="keyvault.list_secrets()", - ) - - def _check_acs_credential(self, result: PreflightResult) -> None: - conn = cfg.acs_connection_string - if conn: - parts = { - k.strip().lower(): v.strip() - for k, _, v in (seg.partition("=") for seg in conn.split(";") if "=" in seg) - } - has_ep = bool(parts.get("endpoint")) - self._add( - result, id="secret_acs_present", category="secrets", - name="ACS Connection String", - status="pass" if has_ep else "warn", - detail=( - f"ACS connection string " - f"{'well-formed' if has_ep else 'malformed (missing endpoint)'}" - ), - evidence=f"ACS_CONNECTION_STRING=***({len(conn)} chars)\nhas_endpoint={has_ep}", - command="env: ACS_CONNECTION_STRING", - ) - else: - self._add( - result, id="secret_acs_present", category="secrets", - name="ACS Connection String", - status="skip", - detail="ACS not configured", - evidence="ACS_CONNECTION_STRING=(empty)", - command="env: ACS_CONNECTION_STRING", - ) - - def _check_aoai_credential(self, result: PreflightResult) -> None: - endpoint = cfg.azure_openai_endpoint - key = cfg.azure_openai_api_key - - if endpoint: - self._add( - result, id="secret_aoai_present", category="secrets", - name="Azure OpenAI Configuration", - status="pass", - detail=f"Endpoint configured, {'API key' if key else 'identity auth'} mode", - evidence=( - f"AZURE_OPENAI_ENDPOINT={endpoint}\n" - f"AZURE_OPENAI_API_KEY={'***' if key else '(identity-auth)'}" - ), - command="env: AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY", - ) - else: - self._add( - result, id="secret_aoai_present", category="secrets", - name="Azure OpenAI Configuration", - status="skip", - detail="Azure OpenAI not configured", - evidence="AZURE_OPENAI_ENDPOINT=(empty)", - command="env: AZURE_OPENAI_ENDPOINT", - ) - - def _check_sp_creds_written(self, result: PreflightResult) -> None: - env_data = cfg.env.read_all() - sp_id = env_data.get("RUNTIME_SP_APP_ID", "") - sp_pw = env_data.get("RUNTIME_SP_PASSWORD", "") - sp_tenant = env_data.get("RUNTIME_SP_TENANT", "") - - if not sp_id: - mi_id = env_data.get("ACA_MI_CLIENT_ID", "") - if mi_id: - self._add( - result, id="secret_identity_creds", category="secrets", - name="Runtime Identity Credentials in .env", - status="pass", - detail="Managed identity credentials written to .env", - evidence=( - f"ACA_MI_CLIENT_ID={mi_id}\n" - f"ACA_MI_RESOURCE_ID={env_data.get('ACA_MI_RESOURCE_ID', '?')}" - ), - command="env: ACA_MI_CLIENT_ID, ACA_MI_RESOURCE_ID", - ) - else: - self._add( - result, id="secret_identity_creds", category="secrets", - name="Runtime Identity Credentials in .env", - status="skip", - detail="No runtime identity credentials in .env", - evidence="RUNTIME_SP_APP_ID=(empty)\nACA_MI_CLIENT_ID=(empty)", - command="env: RUNTIME_SP_APP_ID, ACA_MI_CLIENT_ID", - ) - return - - all_set = bool(sp_id and sp_pw and sp_tenant) - self._add( - result, id="secret_identity_creds", category="secrets", - name="SP Credentials in .env", - status="pass" if all_set else "fail", - detail=( - f"app_id={'set' if sp_id else 'MISSING'}, " - f"password={'set' if sp_pw else 'MISSING'}, " - f"tenant={'set' if sp_tenant else 'MISSING'}" - ), - evidence=( - f"RUNTIME_SP_APP_ID={sp_id[:12] + '...' if sp_id else '(empty)'}\n" - f"RUNTIME_SP_PASSWORD={'***' if sp_pw else '(empty)'}\n" - f"RUNTIME_SP_TENANT={sp_tenant or '(empty)'}" - ), - command="env: RUNTIME_SP_APP_ID, RUNTIME_SP_PASSWORD, RUNTIME_SP_TENANT", - ) diff --git a/app/runtime/state/__init__.py b/app/runtime/state/__init__.py index 65e4418..08e6ce6 100644 --- a/app/runtime/state/__init__.py +++ b/app/runtime/state/__init__.py @@ -1,5 +1,8 @@ """Persistent state stores backed by JSON files.""" +from __future__ import annotations + +from ._base import BaseConfigStore from .deploy_state import DeployStateStore, DeploymentRecord from .foundry_iq_config import FoundryIQConfigStore from .infra_config import InfraConfigStore @@ -13,6 +16,7 @@ from .tool_activity_store import ToolActivityStore, get_tool_activity_store __all__ = [ + "BaseConfigStore", "DeployStateStore", "DeploymentRecord", "FoundryIQConfigStore", diff --git a/app/runtime/state/_base.py b/app/runtime/state/_base.py new file mode 100644 index 0000000..21df52b --- /dev/null +++ b/app/runtime/state/_base.py @@ -0,0 +1,127 @@ +"""Base class for dataclass-backed JSON config stores.""" + +from __future__ import annotations + +import json +import logging +from dataclasses import asdict +from pathlib import Path +from typing import Any, Generic, TypeVar + +from ..config.settings import cfg + +logger = logging.getLogger(__name__) + +C = TypeVar("C") + + +class BaseConfigStore(Generic[C]): + """JSON-file-backed config store using a dataclass for schema. + + Subclasses must set class variables: + + - ``_config_type``: the dataclass class used for config schema + - ``_default_filename``: default JSON filename inside ``cfg.data_dir`` + + Optional class variables: + + - ``_log_label``: human label used in warning messages (defaults to filename) + + Override ``_apply_raw`` to customise how JSON fields are mapped onto the + config dataclass (e.g. secret resolution). Override ``_save_data`` to + customise the dict that is serialised to disk (e.g. secret storage). + """ + + _config_type: type[C] + _default_filename: str + _log_label: str = "" + _SECRET_FIELDS: frozenset[str] = frozenset() + _secret_prefix: str = "" + + def __init__(self, path: Path | None = None) -> None: + self._path = path or (cfg.data_dir / self._default_filename) + self._config: C = self._config_type() + self._load() + + @property + def path(self) -> Path: + return self._path + + @property + def config(self) -> C: + return self._config + + def to_dict(self) -> dict[str, Any]: + """Return the config as a plain dict.""" + return asdict(self._config) + + # -- persistence ------------------------------------------------------- + + def _load(self) -> None: + if not self._path.exists(): + return + try: + raw = json.loads(self._path.read_text()) + self._apply_raw(raw) + except Exception as exc: + label = self._log_label or self._default_filename + logger.warning( + "Failed to load %s from %s: %s", label, self._path, exc, exc_info=True, + ) + + def _apply_raw(self, raw: dict[str, Any]) -> None: + """Populate config fields from a raw JSON dict. + + Default implementation sets every dataclass field found in *raw*. + Override for custom deserialisation (e.g. secret resolution). + """ + for field_name in self._config_type.__dataclass_fields__: + if field_name in raw: + setattr(self._config, field_name, raw[field_name]) + + def _save(self) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + self._path.write_text(json.dumps(self._save_data(), indent=2) + "\n") + + def _save_data(self) -> dict[str, Any]: + """Return the data dict to serialise. + + Default implementation returns ``dataclasses.asdict(self._config)``. + Override for custom serialisation (e.g. secret storage). + """ + return asdict(self._config) + + # -- secret helpers ---------------------------------------------------- + + def _store_secrets(self, data: dict[str, Any]) -> dict[str, Any]: + """Replace secret fields with Key Vault references before persisting. + + Only operates when ``_SECRET_FIELDS`` is non-empty and Key Vault is + enabled. Uses ``_secret_prefix`` to namespace the secret names. + """ + from ..services.keyvault import env_key_to_secret_name, is_kv_ref, kv + + result = dict(data) + if not kv.enabled or not self._SECRET_FIELDS: + return result + prefix = self._secret_prefix + for k in self._SECRET_FIELDS: + val = result.get(k, "") + if val and not is_kv_ref(val): + try: + ref = kv.store(env_key_to_secret_name(f"{prefix}{k}"), val) + result[k] = ref + except Exception as exc: + logger.warning( + "Failed to store secret %s in KV: %s", k, exc, exc_info=True, + ) + return result + + @staticmethod + def _resolve_secret(value: Any) -> Any: + """Resolve a possible Key Vault reference back to its plaintext.""" + if not isinstance(value, str): + return value + from ..services.keyvault import resolve_if_kv_ref + + return resolve_if_kv_ref(value) diff --git a/app/runtime/state/_json_store.py b/app/runtime/state/_json_store.py index be448af..575c79b 100644 --- a/app/runtime/state/_json_store.py +++ b/app/runtime/state/_json_store.py @@ -29,7 +29,7 @@ def load(self) -> Any: try: return json.loads(self._path.read_text()) except (json.JSONDecodeError, OSError) as exc: - logger.warning("Failed to load %s: %s", self._path, exc) + logger.warning("Failed to load %s: %s", self._path, exc, exc_info=True) return self._default_copy() def save(self, data: Any) -> None: diff --git a/app/runtime/state/deploy_state.py b/app/runtime/state/deploy_state.py index 992df9b..2e53003 100644 --- a/app/runtime/state/deploy_state.py +++ b/app/runtime/state/deploy_state.py @@ -129,17 +129,26 @@ def by_kind(self, kind: str) -> list[DeploymentRecord]: return [d for d in self._deployments.values() if d.kind == kind] def current_local(self) -> DeploymentRecord | None: - local = [d for d in self._deployments.values() if d.kind == "local" and d.status == "active"] + local = [ + d for d in self._deployments.values() + if d.kind == "local" and d.status == "active" + ] return max(local, key=lambda d: d.updated_at) if local else None def current_aca(self) -> DeploymentRecord | None: - aca = [d for d in self._deployments.values() if d.kind == "aca" and d.status == "active"] + aca = [ + d for d in self._deployments.values() + if d.kind == "aca" and d.status == "active" + ] return max(aca, key=lambda d: d.updated_at) if aca else None def register(self, record: DeploymentRecord) -> None: self._deployments[record.deploy_id] = record self._save() - logger.info("Registered deployment %s (kind=%s, tag=%s)", record.deploy_id, record.kind, record.tag) + logger.info( + "Registered deployment %s (kind=%s, tag=%s)", + record.deploy_id, record.kind, record.tag, + ) def update(self, record: DeploymentRecord) -> None: record.touch() @@ -193,7 +202,9 @@ def _load(self) -> None: rec.resources = resources self._deployments[did] = rec except Exception as exc: - logger.warning("Failed to load deploy state from %s: %s", self._path, exc) + logger.warning( + "Failed to load deploy state from %s: %s", self._path, exc, exc_info=True, + ) def _save(self) -> None: self._path.parent.mkdir(parents=True, exist_ok=True) diff --git a/app/runtime/state/foundry_iq_config.py b/app/runtime/state/foundry_iq_config.py index 53a4244..d3df172 100644 --- a/app/runtime/state/foundry_iq_config.py +++ b/app/runtime/state/foundry_iq_config.py @@ -2,13 +2,11 @@ from __future__ import annotations -import json import logging from dataclasses import asdict, dataclass -from pathlib import Path from typing import Any -from ..config.settings import cfg +from ._base import BaseConfigStore logger = logging.getLogger(__name__) @@ -33,23 +31,14 @@ class FoundryIQConfig: provisioned: bool = False -class FoundryIQConfigStore: +class FoundryIQConfigStore(BaseConfigStore[FoundryIQConfig]): """JSON-file-backed Foundry IQ configuration.""" + _config_type = FoundryIQConfig + _default_filename = "foundry_iq.json" + _log_label = "Foundry IQ config" _SECRET_FIELDS = frozenset({"search_api_key", "embedding_api_key"}) - - def __init__(self, path: Path | None = None) -> None: - self._path = path or (cfg.data_dir / "foundry_iq.json") - self._config = FoundryIQConfig() - self._load() - - @property - def path(self) -> Path: - return self._path - - @property - def config(self) -> FoundryIQConfig: - return self._config + _secret_prefix = "foundryiq-" @property def enabled(self) -> bool: @@ -105,46 +94,17 @@ def clear_provisioning(self) -> None: self._config.enabled = False self._save() - def _load(self) -> None: - if not self._path.exists(): - return - try: - raw = json.loads(self._path.read_text()) - for k in FoundryIQConfig.__dataclass_fields__: - if k in raw: - value = raw[k] - if k in self._SECRET_FIELDS and isinstance(value, str): - value = self._resolve_secret(value) - setattr(self._config, k, value) - except Exception as exc: - logger.warning("Failed to load Foundry IQ config from %s: %s", self._path, exc) - - def _save(self) -> None: - self._path.parent.mkdir(parents=True, exist_ok=True) + def _apply_raw(self, raw: dict[str, Any]) -> None: + for k in FoundryIQConfig.__dataclass_fields__: + if k in raw: + value = raw[k] + if k in self._SECRET_FIELDS and isinstance(value, str): + value = self._resolve_secret(value) + setattr(self._config, k, value) + + def _save_data(self) -> dict[str, Any]: data = asdict(self._config) - data = self._store_secrets(data) - self._path.write_text(json.dumps(data, indent=2) + "\n") - - def _store_secrets(self, d: dict[str, Any]) -> dict[str, Any]: - from ..services.keyvault import kv, env_key_to_secret_name, is_kv_ref - - result = dict(d) - if not kv.enabled: - return result - for k in self._SECRET_FIELDS: - val = result.get(k, "") - if val and not is_kv_ref(val): - try: - ref = kv.store(env_key_to_secret_name(f"foundryiq-{k}"), val) - result[k] = ref - except Exception as exc: - logger.warning("Failed to store secret %s in KV: %s", k, exc) - return result - - @staticmethod - def _resolve_secret(value: str) -> str: - from ..services.keyvault import resolve_if_kv_ref - return resolve_if_kv_ref(value) + return self._store_secrets(data) # -- singleton ------------------------------------------------------------- diff --git a/app/runtime/state/guardrails/__init__.py b/app/runtime/state/guardrails/__init__.py new file mode 100644 index 0000000..0b8d481 --- /dev/null +++ b/app/runtime/state/guardrails/__init__.py @@ -0,0 +1,49 @@ +"""Guardrails -- policy engine, presets, risk tiers, and bulk operations.""" + +from __future__ import annotations + +from .config import ( + GuardrailsConfigStore, + get_guardrails_config, +) +from .models import ( + GuardrailRule, + GuardrailsConfig, + _VALID_STRATEGIES, +) +from .presets import ( + PRESET_BALANCED, + PRESET_PERMISSIVE, + PRESET_RESTRICTIVE, + _ALL_PRESET_TOOL_IDS, + _build_preset_policies, + list_background_agents, + list_presets, +) +from .risk import ( + _MODEL_TIERS, + _risk_of, + get_model_tier, + get_preset_for_model, + list_model_tiers, +) + +__all__ = [ + "GuardrailRule", + "GuardrailsConfig", + "GuardrailsConfigStore", + "PRESET_BALANCED", + "PRESET_PERMISSIVE", + "PRESET_RESTRICTIVE", + "_ALL_PRESET_TOOL_IDS", + "_MODEL_TIERS", + "_VALID_STRATEGIES", + "_build_preset_policies", + "_risk_of", + "get_guardrails_config", + "get_model_tier", + "get_preset_for_model", + "list_background_agents", + "list_model_tiers", + "list_presets", +] diff --git a/app/runtime/state/guardrails/bulk.py b/app/runtime/state/guardrails/bulk.py new file mode 100644 index 0000000..257b5f5 --- /dev/null +++ b/app/runtime/state/guardrails/bulk.py @@ -0,0 +1,123 @@ +"""Bulk guardrails operations -- presets, strategies, and model defaults.""" + +from __future__ import annotations + +from .models import GuardrailsConfig, _VALID_STRATEGIES +from .presets import ( + PRESET_BALANCED, + PRESET_PERMISSIVE, + PRESET_RESTRICTIVE, + _ALL_PRESET_TOOL_IDS, + _EFFECTIVE_MODEL_PRESET, + _PRESET_MATRIX, + _PRESET_OVERRIDES, + _build_preset_policies, + list_presets, +) +from .risk import ( + _MODEL_TIERS, + _risk_of, + get_model_tier, + get_preset_for_model, +) + + +def apply_preset_to_config( + config: GuardrailsConfig, preset: str, *, auto_models: bool = True, +) -> None: + """Apply a named preset to *config* in place. + + Overwrites ``context_defaults`` and ``tool_policies``. When + *auto_models* is ``True``, recommended models are added as model + columns with tier-appropriate policies and all existing model + columns are refreshed. + """ + valid = {PRESET_RESTRICTIVE, PRESET_BALANCED, PRESET_PERMISSIVE} + if preset not in valid: + raise ValueError("preset must be one of: %s" % ", ".join(sorted(valid))) + policies = _build_preset_policies(preset) + config.context_defaults = policies["context_defaults"] + config.tool_policies = policies["tool_policies"] + config.hitl_enabled = True + if auto_models: + preset_meta = next((p for p in list_presets() if p["id"] == preset), None) + if preset_meta: + new_models = [ + m for m in preset_meta["recommended_for"] + if m not in config.model_columns + ] + if new_models: + apply_model_defaults_to_config(config, new_models, preset=preset) + if config.model_columns: + apply_model_defaults_to_config(config, preset=preset) + + +def set_all_strategies_on_config(config: GuardrailsConfig, strategy: str) -> None: + """Set every tool policy and context default on *config* to *strategy*. + + All tools in ``_ALL_PRESET_TOOL_IDS`` across interactive and background + contexts are set to the given strategy. All known models from + ``_MODEL_TIERS`` are added as model columns with the same strategy. + """ + if strategy not in _VALID_STRATEGIES: + raise ValueError( + "strategy must be one of: %s" % ", ".join(sorted(_VALID_STRATEGIES)) + ) + policies: dict[str, dict[str, str]] = {"interactive": {}, "background": {}} + for tool_id in _ALL_PRESET_TOOL_IDS: + for ctx in ("interactive", "background"): + policies[ctx][tool_id] = strategy + config.context_defaults = { + "interactive": strategy, + "background": strategy, + } + config.tool_policies = policies + config.model_columns = sorted(_MODEL_TIERS.keys()) + model_policies: dict[str, dict[str, dict[str, str]]] = {} + for model in config.model_columns: + per_ctx: dict[str, dict[str, str]] = {} + for ctx in ("interactive", "background"): + per_ctx[ctx] = {tool_id: strategy for tool_id in _ALL_PRESET_TOOL_IDS} + model_policies[model] = per_ctx + config.model_policies = model_policies + config.hitl_enabled = True + + +def apply_model_defaults_to_config( + config: GuardrailsConfig, + models: list[str] | None = None, + *, + preset: str | None = None, +) -> None: + """Auto-populate model columns on *config* with tier-appropriate policies. + + For each model, determines the effective preset via the + ``_EFFECTIVE_MODEL_PRESET`` cross-reference of *preset* (the + user-selected risk posture) and the model's inherent tier. + + If *models* is ``None``, uses the existing ``model_columns``. + If *preset* is ``None``, falls back to the model's own tier preset. + """ + target_models = models if models is not None else list(config.model_columns) + for model in target_models: + if model not in config.model_columns: + config.model_columns.append(model) + tier = get_model_tier(model) + if preset: + effective = _EFFECTIVE_MODEL_PRESET.get( + (preset, tier), + get_preset_for_model(model), + ) + else: + effective = get_preset_for_model(model) + matrix = _PRESET_MATRIX.get(effective, _PRESET_MATRIX[PRESET_RESTRICTIVE]) + overrides = _PRESET_OVERRIDES.get(effective, {}) + per_ctx: dict[str, dict[str, str]] = {} + for ctx in ("interactive", "background"): + ctx_overrides = overrides.get(ctx, {}) + ctx_policies: dict[str, str] = {} + for tool_id in _ALL_PRESET_TOOL_IDS: + risk = _risk_of(tool_id) + ctx_policies[tool_id] = ctx_overrides.get(tool_id, matrix[ctx][risk]) + per_ctx[ctx] = ctx_policies + config.model_policies[model] = per_ctx diff --git a/app/runtime/state/guardrails/config.py b/app/runtime/state/guardrails/config.py new file mode 100644 index 0000000..69b1bc0 --- /dev/null +++ b/app/runtime/state/guardrails/config.py @@ -0,0 +1,505 @@ +"""Guardrails configuration -- HITL approval rules for tools and MCP servers.""" + +from __future__ import annotations + +import json +import logging +from dataclasses import asdict +from pathlib import Path +from typing import Any + +from ...agent.policy_bridge import ( + build_engine, + config_to_yaml, + make_eval_context, + validate_yaml, + yaml_to_config, +) +from ...config.settings import cfg + +from .bulk import ( + apply_model_defaults_to_config, + apply_preset_to_config, + set_all_strategies_on_config, +) + +# Re-export public symbols so existing imports keep working. +from .models import GuardrailRule, GuardrailsConfig, _VALID_STRATEGIES # noqa: F401 +from .presets import ( # noqa: F401 + PRESET_BALANCED, + PRESET_PERMISSIVE, + PRESET_RESTRICTIVE, + _ALL_PRESET_TOOL_IDS, + _build_preset_policies, + list_background_agents, + list_presets, +) +from .risk import ( # noqa: F401 + _MODEL_TIERS, + _risk_of, + get_model_tier, + get_preset_for_model, + list_model_tiers, +) + +logger = logging.getLogger(__name__) + +_instance: GuardrailsConfigStore | None = None + + +class GuardrailsConfigStore: + """JSON-file-backed guardrails configuration. + + The store maintains both a JSON file (UI state, phone numbers, AITL + config, etc.) and a YAML policy file consumed by the agent-policy-guard + ``PolicyEngine``. Every mutation regenerates the YAML and rebuilds + the engine so that ``resolve_action()`` always reflects the latest + configuration. + """ + + def __init__(self, path: Path | None = None) -> None: + self._path = path or (cfg.data_dir / "guardrails.json") + self._policy_path = self._path.with_name("policy.yaml") + self._config = GuardrailsConfig() + self._engine = build_engine(self._generate_yaml()) + self._load() + + @property + def path(self) -> Path: + return self._path + + @property + def config(self) -> GuardrailsConfig: + return self._config + + @property + def hitl_enabled(self) -> bool: + return self._config.hitl_enabled + + @property + def default_action(self) -> str: + return self._config.default_action + + @property + def rules(self) -> list[GuardrailRule]: + return list(self._config.rules) + + def set_hitl_enabled(self, enabled: bool) -> None: + self._config.hitl_enabled = enabled + self._save() + + def set_default_action(self, action: str) -> None: + if action not in _VALID_STRATEGIES: + raise ValueError("action must be one of: %s" % ", ".join(sorted(_VALID_STRATEGIES))) + self._config.default_action = action + self._save() + + @property + def default_channel(self) -> str: + return self._config.default_channel + + @property + def phone_number(self) -> str: + return self._config.phone_number + + def set_default_channel(self, channel: str) -> None: + if channel not in ("chat", "phone"): + raise ValueError("channel must be 'chat' or 'phone'") + self._config.default_channel = channel + self._save() + + def set_phone_number(self, number: str) -> None: + self._config.phone_number = number + self._save() + + def set_aitl_model(self, model: str) -> None: + self._config.aitl_model = model + self._save() + + def set_aitl_spotlighting(self, enabled: bool) -> None: + self._config.aitl_spotlighting = enabled + self._save() + + def set_filter_mode(self, mode: str) -> None: + if mode != "prompt_shields": + raise ValueError("filter_mode must be 'prompt_shields'") + self._config.filter_mode = mode + self._save() + + def set_content_safety_endpoint(self, endpoint: str) -> None: + self._config.content_safety_endpoint = endpoint + self._save() + + def set_content_safety_key(self, key: str) -> None: + self._config.content_safety_key = key + self._save() + + def set_context_default(self, context: str, strategy: str) -> None: + if strategy not in _VALID_STRATEGIES: + raise ValueError("strategy must be one of: %s" % ", ".join(sorted(_VALID_STRATEGIES))) + self._config.context_defaults[context] = strategy + self._save() + + def remove_context_default(self, context: str) -> bool: + """Remove a context-level default, reverting to fallback resolution.""" + if context in self._config.context_defaults: + del self._config.context_defaults[context] + self._save() + return True + return False + + def set_tool_policy( + self, context: str, tool_id: str, strategy: str, + ) -> None: + if strategy not in _VALID_STRATEGIES: + raise ValueError("strategy must be one of: %s" % ", ".join(sorted(_VALID_STRATEGIES))) + if context not in self._config.tool_policies: + self._config.tool_policies[context] = {} + self._config.tool_policies[context][tool_id] = strategy + self._save() + + def remove_tool_policy(self, context: str, tool_id: str) -> bool: + policies = self._config.tool_policies.get(context, {}) + if tool_id in policies: + del policies[tool_id] + self._save() + return True + return False + + def add_model_column(self, model: str) -> None: + if model not in self._config.model_columns: + self._config.model_columns.append(model) + self._save() + + def remove_model_column(self, model: str) -> bool: + if model in self._config.model_columns: + self._config.model_columns.remove(model) + self._config.model_policies.pop(model, None) + self._save() + return True + return False + + def set_model_policy( + self, model: str, tool_id: str, strategy: str, context: str = "interactive", + ) -> None: + if strategy not in _VALID_STRATEGIES: + raise ValueError("strategy must be one of: %s" % ", ".join(sorted(_VALID_STRATEGIES))) + if model not in self._config.model_policies: + self._config.model_policies[model] = {} + if context not in self._config.model_policies[model]: + self._config.model_policies[model][context] = {} + self._config.model_policies[model][context][tool_id] = strategy + self._save() + + def remove_model_policy( + self, model: str, tool_id: str, context: str = "interactive", + ) -> bool: + ctx_policies = self._config.model_policies.get(model, {}).get(context, {}) + if tool_id in ctx_policies: + del ctx_policies[tool_id] + self._save() + return True + return False + + def apply_preset(self, preset: str, *, auto_models: bool = True) -> None: + """Apply a named preset to context_defaults and tool_policies.""" + apply_preset_to_config(self._config, preset, auto_models=auto_models) + self._save() + + def set_all_strategies(self, strategy: str) -> None: + """Set every tool policy and context default to *strategy*.""" + set_all_strategies_on_config(self._config, strategy) + self._save() + + def apply_model_defaults( + self, + models: list[str] | None = None, + *, + preset: str | None = None, + ) -> None: + """Auto-populate model columns with tier-appropriate policies.""" + apply_model_defaults_to_config(self._config, models, preset=preset) + self._save() + + def add_rule( + self, + *, + name: str, + pattern: str, + scope: str = "tool", + action: str = "ask", + enabled: bool = True, + description: str = "", + contexts: list[str] | None = None, + models: list[str] | None = None, + hitl_channel: str = "chat", + ) -> GuardrailRule: + if scope not in ("tool", "mcp"): + raise ValueError("scope must be 'tool' or 'mcp'") + if action not in _VALID_STRATEGIES: + raise ValueError("action must be one of: %s" % ", ".join(sorted(_VALID_STRATEGIES))) + if hitl_channel not in ("chat", "phone"): + raise ValueError("hitl_channel must be 'chat' or 'phone'") + rule = GuardrailRule( + name=name, + pattern=pattern, + scope=scope, + action=action, + enabled=enabled, + description=description, + contexts=contexts or [], + models=models or [], + hitl_channel=hitl_channel, + ) + self._config.rules.append(rule) + self._save() + return rule + + def update_rule(self, rule_id: str, **kwargs: Any) -> GuardrailRule | None: + for rule in self._config.rules: + if rule.id == rule_id: + for k, v in kwargs.items(): + if k == "id": + continue + if hasattr(rule, k): + setattr(rule, k, v) + self._save() + return rule + return None + + def remove_rule(self, rule_id: str) -> bool: + before = len(self._config.rules) + self._config.rules = [r for r in self._config.rules if r.id != rule_id] + if len(self._config.rules) < before: + self._save() + return True + return False + + def get_rule(self, rule_id: str) -> GuardrailRule | None: + for rule in self._config.rules: + if rule.id == rule_id: + return rule + return None + + def resolve_action( + self, + tool_name: str, + mcp_server: str | None = None, + execution_context: str = "", + model: str = "", + ) -> str: + """Determine the strategy for a given tool invocation. + + Delegates to the agent-policy-guard ``PolicyEngine`` which evaluates + the generated YAML policy set. The YAML encodes all context defaults, + tool policies, model policies, legacy rules, and background-agent + fallbacks. + + When ``hitl_enabled`` is ``False`` the engine already has ``allow`` + as its default effect and no policies are generated, so it returns + ``"allow"`` for every call. + """ + ctx = make_eval_context( + tool_name=tool_name, + mcp_server=mcp_server, + execution_context=execution_context, + model=model, + ) + result = self._engine.resolve(ctx) + logger.debug( + "[guardrails.resolve] engine result: tool=%s ctx=%s model=%s -> %s", + tool_name, execution_context, model, result, + ) + return result + + def resolve_channel( + self, + tool_name: str, + mcp_server: str | None = None, + execution_context: str = "", + model: str = "", + ) -> str: + """Determine the HITL channel for a tool invocation. + + Returns the ``hitl_channel`` of the first matching rule, or the + store-level ``default_channel``. + """ + if not self._config.hitl_enabled: + return "chat" + + for rule in self._config.rules: + if not rule.enabled: + continue + if rule.contexts and execution_context and execution_context not in rule.contexts: + continue + if rule.models and model: + if not any(self._matches(m, model) for m in rule.models): + continue + if rule.scope == "tool" and self._matches(rule.pattern, tool_name): + return rule.hitl_channel + if rule.scope == "mcp" and mcp_server and self._matches(rule.pattern, mcp_server): + return rule.hitl_channel + + return self._config.default_channel + + def to_dict(self) -> dict[str, Any]: + return { + # Frontend-canonical fields + "enabled": self._config.hitl_enabled, + "default_strategy": self._config.default_action, + "hitl_channel": self._config.default_channel, + "context_defaults": dict(self._config.context_defaults), + "tool_policies": { + ctx: dict(policies) + for ctx, policies in self._config.tool_policies.items() + }, + "model_columns": list(self._config.model_columns), + "model_policies": { + model: { + ctx: dict(tool_map) + for ctx, tool_map in ctx_policies.items() + } + for model, ctx_policies in self._config.model_policies.items() + }, + # Backend / legacy fields + "hitl_enabled": self._config.hitl_enabled, + "default_action": self._config.default_action, + "default_channel": self._config.default_channel, + "phone_number": self._config.phone_number, + "aitl_model": self._config.aitl_model, + "aitl_spotlighting": self._config.aitl_spotlighting, + "filter_mode": self._config.filter_mode, + "content_safety_endpoint": self._config.content_safety_endpoint, + "rules": [asdict(r) for r in self._config.rules], + } + + @staticmethod + def _matches(pattern: str, name: str) -> bool: + """Simple glob-style matching: '*' matches everything, prefix* matches prefix.""" + if pattern == "*": + return True + if pattern.endswith("*"): + return name.startswith(pattern[:-1]) + return pattern == name + + def _load(self) -> None: + if not self._path.exists(): + self._rebuild_engine() + return + try: + raw = json.loads(self._path.read_text()) + self._config = GuardrailsConfig( + hitl_enabled=raw.get("enabled", raw.get("hitl_enabled", False)), + default_action=raw.get("default_strategy", raw.get("default_action", "allow")), + default_channel=raw.get("hitl_channel", raw.get("default_channel", "chat")), + phone_number=raw.get("phone_number", ""), + aitl_model=raw.get("aitl_model", "gpt-4.1"), + aitl_spotlighting=raw.get("aitl_spotlighting", True), + filter_mode=raw.get("filter_mode", "prompt_shields"), + content_safety_endpoint=raw.get("content_safety_endpoint", ""), + content_safety_key=raw.get("content_safety_key", ""), + rules=[ + GuardrailRule(**{ + k: v for k, v in r.items() + if k in GuardrailRule.__dataclass_fields__ + }) + for r in raw.get("rules", []) + ], + context_defaults=raw.get("context_defaults", {}), + tool_policies=raw.get("tool_policies", {}), + model_columns=raw.get("model_columns", []), + model_policies=raw.get("model_policies", {}), + ) + self._rebuild_engine() + except Exception as exc: + logger.warning( + "Failed to load guardrails config from %s: %s", + self._path, exc, exc_info=True, + ) + + @property + def policy_path(self) -> Path: + """Path to the generated policy YAML file.""" + return self._policy_path + + def get_policy_yaml(self) -> str: + """Return the current policy as a YAML string.""" + return self._generate_yaml() + + def set_policy_yaml(self, yaml_text: str) -> str | None: + """Apply a raw YAML policy, updating the config to match. + + Returns ``None`` on success or an error message string. + """ + error = validate_yaml(yaml_text) + if error: + return error + try: + parsed = yaml_to_config(yaml_text) + self._config.default_action = parsed["default_action"] + self._config.default_channel = parsed["default_channel"] + self._config.context_defaults = parsed["context_defaults"] + self._config.tool_policies = parsed["tool_policies"] + self._config.model_columns = parsed["model_columns"] + self._config.model_policies = parsed["model_policies"] + if parsed.get("rules"): + self._config.rules = [ + GuardrailRule(**{ + k: v for k, v in r.items() + if k in GuardrailRule.__dataclass_fields__ + }) + for r in parsed["rules"] + ] + self._save() + return None + except Exception as exc: + logger.warning("[guardrails] failed to apply YAML: %s", exc, exc_info=True) + return str(exc) + + def _generate_yaml(self) -> str: + """Generate a policy YAML string from the current config.""" + return config_to_yaml( + hitl_enabled=self._config.hitl_enabled, + default_action=self._config.default_action, + default_channel=self._config.default_channel, + context_defaults=self._config.context_defaults, + tool_policies=self._config.tool_policies, + model_columns=self._config.model_columns, + model_policies=self._config.model_policies, + rules=[asdict(r) for r in self._config.rules], + ) + + def _rebuild_engine(self) -> None: + """Rebuild the PolicyEngine from the current config.""" + yaml_text = self._generate_yaml() + self._engine = build_engine(yaml_text) + # Write the YAML file alongside the JSON for reference / expert mode + try: + self._policy_path.write_text(yaml_text) + except Exception as exc: + logger.warning( + "[guardrails] failed to write policy.yaml: %s", exc, exc_info=True, + ) + + def _save(self) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + self._path.write_text(json.dumps(self.to_dict(), indent=2) + "\n") + self._rebuild_engine() + + +def get_guardrails_config(path: Path | None = None) -> GuardrailsConfigStore: + """Module-level singleton accessor.""" + global _instance + if _instance is None: + _instance = GuardrailsConfigStore(path) + return _instance + + +def _reset_guardrails_config() -> None: + global _instance + _instance = None + + +from ...util.singletons import register_singleton # noqa: E402 + +register_singleton(_reset_guardrails_config) diff --git a/app/runtime/state/guardrails/models.py b/app/runtime/state/guardrails/models.py new file mode 100644 index 0000000..f0a0a55 --- /dev/null +++ b/app/runtime/state/guardrails/models.py @@ -0,0 +1,52 @@ +"""Guardrails data models -- dataclasses and shared constants.""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field + +_VALID_STRATEGIES = frozenset({"allow", "deny", "hitl", "pitl", "aitl", "filter", "ask"}) + + +@dataclass +class GuardrailRule: + """A single approval rule for a tool or MCP server.""" + + id: str = "" + name: str = "" + pattern: str = "" + scope: str = "tool" # "tool" | "mcp" + action: str = "allow" # "allow" | "deny" | "ask" + enabled: bool = True + description: str = "" + # Context-aware policy fields + contexts: list[str] = field(default_factory=list) # [] = all contexts + models: list[str] = field(default_factory=list) # [] = all models + hitl_channel: str = "chat" # "chat" | "phone" + + def __post_init__(self) -> None: + if not self.id: + self.id = str(uuid.uuid4())[:8] + + +@dataclass +class GuardrailsConfig: + """Top-level guardrails configuration.""" + + hitl_enabled: bool = False + default_action: str = "allow" # "allow" | "deny" | "hitl" | "pitl" | "aitl" | "filter" + default_channel: str = "chat" # "chat" | "phone" + phone_number: str = "" # E.164 number for phone verification + aitl_model: str = "gpt-4.1" # Model used by the AITL reviewer agent + aitl_spotlighting: bool = True # Spotlight untrusted content in AITL prompts + filter_mode: str = "prompt_shields" # always "prompt_shields" + content_safety_endpoint: str = "" # Azure Content Safety endpoint URL + content_safety_key: str = "" # Azure Content Safety API key + rules: list[GuardrailRule] = field(default_factory=list) + # Policy matrix fields (frontend-driven) + context_defaults: dict[str, str] = field(default_factory=dict) + tool_policies: dict[str, dict[str, str]] = field(default_factory=dict) + # Model-specific columns: user-defined model identifiers + model_columns: list[str] = field(default_factory=list) + # Model-scoped policies: model -> context -> tool -> strategy + model_policies: dict[str, dict[str, dict[str, str]]] = field(default_factory=dict) diff --git a/app/runtime/state/guardrails/presets.py b/app/runtime/state/guardrails/presets.py new file mode 100644 index 0000000..248a217 --- /dev/null +++ b/app/runtime/state/guardrails/presets.py @@ -0,0 +1,269 @@ +"""Preset definitions and background agent metadata for guardrails.""" + +from __future__ import annotations + +from typing import Any + +from .risk import _risk_of + +# ── Background agent metadata ─────────────────────────────────────────── + +BACKGROUND_AGENTS: tuple[dict[str, Any], ...] = ( + { + "id": "scheduler", + "name": "Scheduler", + "description": ( + "Runs scheduled tasks on a cron schedule. Has full tool access " + "including file operations, terminal, and MCP servers." + ), + "has_tools": True, + "default_policy": "background", + "risk_note": ( + "Changing the policy for the scheduler may cause scheduled tasks " + "to hang waiting for approval or fail silently." + ), + }, + { + "id": "bot_processor", + "name": "Bot Message Processor", + "description": ( + "Processes messages from Teams, Telegram, and other bot channels. " + "Shares the full tool set with the interactive agent." + ), + "has_tools": True, + "default_policy": "background", + "risk_note": ( + "Changing the policy for the bot processor may cause channel " + "messages to hang or tools to be blocked for bot users." + ), + }, + { + "id": "proactive_loop", + "name": "Proactive Loop", + "description": ( + "Generates proactive messages and notifications. Text-only -- " + "has no tool access." + ), + "has_tools": False, + "default_policy": "allow", + "risk_note": ( + "This agent has no tool access. Guardrail changes have no effect." + ), + }, + { + "id": "memory_formation", + "name": "Memory Formation", + "description": ( + "Post-processes conversations to extract and store memories. " + "Text-only -- has no tool access." + ), + "has_tools": False, + "default_policy": "allow", + "risk_note": ( + "This agent has no tool access. Guardrail changes have no effect." + ), + }, + { + "id": "aitl_reviewer", + "name": "AITL Reviewer", + "description": ( + "AI reviewer that evaluates tool calls for safety. Uses one " + "internal decision tool (submit_decision)." + ), + "has_tools": True, + "default_policy": "allow", + "risk_note": ( + "The AITL reviewer IS the guardrail. Restricting it will " + "prevent it from functioning and break AITL-based approvals." + ), + }, + { + "id": "realtime", + "name": "Realtime Voice Agent", + "description": ( + "Bridges the Realtime voice model to the Copilot SDK agent. " + "Spawns one-shot sessions to execute tool-based tasks requested " + "via voice calls." + ), + "has_tools": True, + "default_policy": "background", + "risk_note": ( + "Changing the policy for the realtime agent may cause voice " + "call tool invocations to hang or be blocked." + ), + }, +) + +_BACKGROUND_AGENT_IDS: frozenset[str] = frozenset( + a["id"] for a in BACKGROUND_AGENTS +) + + +def list_background_agents() -> list[dict[str, Any]]: + """Return metadata for all background agents.""" + return list(BACKGROUND_AGENTS) + + +# ── Preset constants ──────────────────────────────────────────────────── + +PRESET_RESTRICTIVE = "restrictive" +PRESET_BALANCED = "balanced" +PRESET_PERMISSIVE = "permissive" + +_TIER_TO_PRESET: dict[int, str] = { + 1: PRESET_PERMISSIVE, + 2: PRESET_BALANCED, + 3: PRESET_RESTRICTIVE, +} + +# Cross-reference: (selected_preset, model_tier) -> effective preset for model-column +# policies. This ensures that switching presets actually changes per-model rules +# while still respecting each model's inherent safety tier. +_EFFECTIVE_MODEL_PRESET: dict[tuple[str, int], str] = { + # Permissive preset: strong/standard models get permissive, cautious gets balanced + (PRESET_PERMISSIVE, 1): PRESET_PERMISSIVE, + (PRESET_PERMISSIVE, 2): PRESET_PERMISSIVE, + (PRESET_PERMISSIVE, 3): PRESET_BALANCED, + # Balanced preset: strong gets permissive, standard balanced, cautious balanced + (PRESET_BALANCED, 1): PRESET_PERMISSIVE, + (PRESET_BALANCED, 2): PRESET_BALANCED, + (PRESET_BALANCED, 3): PRESET_BALANCED, + # Restrictive preset: strong gets balanced, standard/cautious get restrictive + (PRESET_RESTRICTIVE, 1): PRESET_BALANCED, + (PRESET_RESTRICTIVE, 2): PRESET_RESTRICTIVE, + (PRESET_RESTRICTIVE, 3): PRESET_RESTRICTIVE, +} + +# Strategy lookup: (preset, context, risk) -> strategy +_PRESET_MATRIX: dict[str, dict[str, dict[str, str]]] = { + PRESET_PERMISSIVE: { + "interactive": {"low": "filter", "medium": "filter", "high": "filter"}, + "background": {"low": "filter", "medium": "filter", "high": "hitl"}, + }, + PRESET_BALANCED: { + "interactive": {"low": "filter", "medium": "filter", "high": "hitl"}, + "background": {"low": "filter", "medium": "hitl", "high": "deny"}, + }, + PRESET_RESTRICTIVE: { + "interactive": {"low": "filter", "medium": "hitl", "high": "hitl"}, + "background": {"low": "filter", "medium": "deny", "high": "deny"}, + }, +} + +# Per-preset tool overrides applied *after* the risk matrix. +_PRESET_OVERRIDES: dict[str, dict[str, dict[str, str]]] = { + PRESET_BALANCED: { + "background": { + "create": "aitl", + "edit": "aitl", + "run": "aitl", + "bash": "aitl", + "make_voice_call": "aitl", + }, + }, +} + +# Every tool/MCP/skill that presets should populate explicitly. +_ALL_PRESET_TOOL_IDS: list[str] = [ + # SDK + "create", "edit", "view", "grep", "glob", "run", "bash", + # Custom agent tools + "schedule_task", "cancel_task", "list_scheduled_tasks", "make_voice_call", + "send_adaptive_card", "send_hero_card", "send_thumbnail_card", "send_card_carousel", + "search_memories_tool", + # MCP + "mcp:microsoft-learn", "mcp:playwright", "mcp:github-mcp-server", "mcp:azure-mcp-server", + # Skills (builtin) + "skill:web-search", "skill:summarize-url", "skill:note-taking", "skill:daily-briefing", +] + +# Restrictiveness ranking for merging model policies across contexts. +_STRATEGY_RANK: dict[str, int] = { + "allow": 0, + "filter": 1, + "aitl": 2, + "hitl": 3, + "pitl": 4, + "ask": 4, + "deny": 5, +} + + +def _strategy_rank(strategy: str) -> int: + return _STRATEGY_RANK.get(strategy, 3) + + +def _build_preset_policies(preset: str) -> dict[str, Any]: + """Return context_defaults and tool_policies for a given preset name. + + Uses the ``_PRESET_MATRIX`` to map (preset, context, risk) -> strategy + for every known tool/MCP/skill. + """ + matrix = _PRESET_MATRIX.get(preset, _PRESET_MATRIX[PRESET_RESTRICTIVE]) + overrides = _PRESET_OVERRIDES.get(preset, {}) + policies: dict[str, dict[str, str]] = {"interactive": {}, "background": {}} + for tool_id in _ALL_PRESET_TOOL_IDS: + risk = _risk_of(tool_id) + for ctx in ("interactive", "background"): + policies[ctx][tool_id] = matrix[ctx][risk] + # Apply per-tool overrides after the matrix + for ctx, tool_map in overrides.items(): + for tool_id, strategy in tool_map.items(): + policies[ctx][tool_id] = strategy + # Context-level defaults (for tools not explicitly listed) + ctx_defaults = { + ctx: matrix[ctx]["medium"] for ctx in ("interactive", "background") + } + return { + "context_defaults": ctx_defaults, + "tool_policies": policies, + } + + +def list_presets() -> list[dict[str, Any]]: + """Return metadata for all available presets.""" + from .risk import _MODEL_TIERS + + return [ + { + "id": PRESET_RESTRICTIVE, + "name": "Restrictive", + "description": ( + "For smaller or older models. Read-only tools allowed; " + "file edits and browser require HITL in interactive; " + "terminal, GitHub, Azure, and all MCP denied in background." + ), + "tier": 3, + "recommended_for": sorted( + m for m, t in _MODEL_TIERS.items() if t == 3 + ), + }, + { + "id": PRESET_BALANCED, + "name": "Balanced", + "description": ( + "For standard models. Low-risk tools allowed everywhere; " + "terminal and GitHub/Azure require HITL in interactive; " + "file operations, terminal, and voice calls use AITL in " + "background; high-risk MCP denied in background. " + "MS Learn allowed." + ), + "tier": 2, + "recommended_for": sorted( + m for m, t in _MODEL_TIERS.items() if t == 2 + ), + }, + { + "id": PRESET_PERMISSIVE, + "name": "Permissive", + "description": ( + "For strong frontier models. All tools allowed in interactive. " + "Terminal, GitHub, Azure still require HITL in background. " + "MS Learn, file operations, and browser allowed everywhere." + ), + "tier": 1, + "recommended_for": sorted( + m for m, t in _MODEL_TIERS.items() if t == 1 + ), + }, + ] diff --git a/app/runtime/state/guardrails/risk.py b/app/runtime/state/guardrails/risk.py new file mode 100644 index 0000000..3485696 --- /dev/null +++ b/app/runtime/state/guardrails/risk.py @@ -0,0 +1,118 @@ +"""Risk classification and model tier definitions for guardrails.""" + +from __future__ import annotations + +from typing import Any + +# ── Model tiers ────────────────────────────────────────────────────────── +# Tier 1 (cautious): large frontier models -- most access, highest risk posture +# Tier 2 (standard): capable mid-range models +# Tier 3 (safe): smaller / older models -- least access, lowest risk posture + +_MODEL_TIERS: dict[str, int] = { + # Tier 1 -- cautious (most permissive, highest risk) + "gpt-5.3-codex": 1, + "claude-opus-4.6": 1, + "claude-opus-4.6-fast": 1, + # Tier 2 -- standard + "claude-sonnet-4.6": 2, + "gpt-5.2": 2, + "gemini-3-pro-preview": 2, + # Tier 3 -- safe (most restrictive, lowest risk) + "gpt-5-mini": 3, + "gpt-4.1": 3, +} + +_DEFAULT_TIER = 3 # Unknown models get the most restrictive tier + +# ── MCP / tool risk classification ────────────────────────────────────── +# Risk levels: low (read-only / public), medium (browser / scheduling), +# high (code repos, infra, phone calls). + +_MCP_RISK: dict[str, str] = { + "mcp:microsoft-learn": "low", # read-only public docs + "mcp:playwright": "medium", # browser automation, can navigate sites + "mcp:github-mcp-server": "high", # create repos, PRs, push code + "mcp:azure-mcp-server": "high", # create/delete Azure resources +} + +_SKILL_RISK: dict[str, str] = { + "skill:daily-briefing": "low", # read-only from local memory + "skill:wiki-search": "low", # read-only, public API + "skill:wiki-summary": "low", + "skill:wiki-deep-dive": "low", + "skill:gh-status-check": "low", # read-only, public API + "skill:gh-incidents": "low", + "skill:gh-maintenance": "low", + "skill:web-search": "medium", # browser-based + "skill:summarize-url": "medium", # browser-based + "skill:note-taking": "medium", # filesystem writes + "skill:daily-rollover": "medium", # M365 reads + file writes + "skill:end-day": "medium", + "skill:weekly-review": "medium", + "skill:monthly-review": "medium", + "skill:setup-foundry": "high", # provisions Azure infra + "skill:foundry-agent-chat": "high", # creates cloud agents + "skill:foundry-code-interpreter": "high", + "skill:setup-workiq": "medium", + "skill:setup-wikipedia": "low", +} + +_CUSTOM_TOOL_RISK: dict[str, str] = { + "schedule_task": "medium", + "cancel_task": "medium", + "list_scheduled_tasks": "low", + "make_voice_call": "high", + "search_memories_tool": "low", + "send_adaptive_card": "low", + "send_hero_card": "low", + "send_thumbnail_card": "low", + "send_card_carousel": "low", +} + + +def _risk_of(tool_id: str) -> str: + """Return the risk level for any tool/MCP/skill id.""" + if tool_id in _MCP_RISK: + return _MCP_RISK[tool_id] + if tool_id in _SKILL_RISK: + return _SKILL_RISK[tool_id] + if tool_id in _CUSTOM_TOOL_RISK: + return _CUSTOM_TOOL_RISK[tool_id] + # SDK tools + if tool_id in ("view", "grep", "glob"): + return "low" + if tool_id in ("create", "edit"): + return "medium" + if tool_id in ("run", "bash"): + return "high" + # Unknown MCP or skill -- default to high for safety + if tool_id.startswith("mcp:") or tool_id.startswith("skill:"): + return "high" + return "medium" + + +def get_model_tier(model: str) -> int: + """Return the security tier for a model (1=cautious, 2=standard, 3=safe).""" + return _MODEL_TIERS.get(model, _DEFAULT_TIER) + + +def get_preset_for_model(model: str) -> str: + """Return the recommended preset name for a model.""" + from .presets import _TIER_TO_PRESET, PRESET_RESTRICTIVE + + return _TIER_TO_PRESET.get(get_model_tier(model), PRESET_RESTRICTIVE) + + +def list_model_tiers() -> list[dict[str, Any]]: + """Return all known models with their tier and recommended preset.""" + result: list[dict[str, Any]] = [] + _TIER_LABELS = {1: "Strong", 2: "Standard", 3: "Cautious"} + for model, tier in sorted(_MODEL_TIERS.items(), key=lambda x: (x[1], x[0])): + result.append({ + "model": model, + "tier": tier, + "tier_label": _TIER_LABELS.get(tier, "Unknown"), + "preset": get_preset_for_model(model), + }) + return result diff --git a/app/runtime/state/guardrails_config.py b/app/runtime/state/guardrails_config.py index a47d07b..f47356f 100644 --- a/app/runtime/state/guardrails_config.py +++ b/app/runtime/state/guardrails_config.py @@ -4,8 +4,7 @@ import json import logging -import uuid -from dataclasses import asdict, dataclass, field +from dataclasses import asdict from pathlib import Path from typing import Any @@ -18,439 +17,34 @@ ) from ..config.settings import cfg -logger = logging.getLogger(__name__) - -_instance: GuardrailsConfigStore | None = None - - -@dataclass -class GuardrailRule: - """A single approval rule for a tool or MCP server.""" - - id: str = "" - name: str = "" - pattern: str = "" - scope: str = "tool" # "tool" | "mcp" - action: str = "allow" # "allow" | "deny" | "ask" - enabled: bool = True - description: str = "" - # Context-aware policy fields - contexts: list[str] = field(default_factory=list) # [] = all contexts - models: list[str] = field(default_factory=list) # [] = all models - hitl_channel: str = "chat" # "chat" | "phone" - - def __post_init__(self) -> None: - if not self.id: - self.id = str(uuid.uuid4())[:8] - - -_VALID_STRATEGIES = frozenset({"allow", "deny", "hitl", "pitl", "aitl", "filter", "ask"}) - -# ── Background agent metadata ─────────────────────────────────────────── -# Each background agent gets its own execution context so policy can be -# set per-agent. ``resolve_action`` falls back from the agent-specific -# context to ``"background"`` when no override exists. - -BACKGROUND_AGENTS: tuple[dict[str, Any], ...] = ( - { - "id": "scheduler", - "name": "Scheduler", - "description": ( - "Runs scheduled tasks on a cron schedule. Has full tool access " - "including file operations, terminal, and MCP servers." - ), - "has_tools": True, - "default_policy": "background", - "risk_note": ( - "Changing the policy for the scheduler may cause scheduled tasks " - "to hang waiting for approval or fail silently." - ), - }, - { - "id": "bot_processor", - "name": "Bot Message Processor", - "description": ( - "Processes messages from Teams, Telegram, and other bot channels. " - "Shares the full tool set with the interactive agent." - ), - "has_tools": True, - "default_policy": "background", - "risk_note": ( - "Changing the policy for the bot processor may cause channel " - "messages to hang or tools to be blocked for bot users." - ), - }, - { - "id": "proactive_loop", - "name": "Proactive Loop", - "description": ( - "Generates proactive messages and notifications. Text-only -- " - "has no tool access." - ), - "has_tools": False, - "default_policy": "allow", - "risk_note": ( - "This agent has no tool access. Guardrail changes have no effect." - ), - }, - { - "id": "memory_formation", - "name": "Memory Formation", - "description": ( - "Post-processes conversations to extract and store memories. " - "Text-only -- has no tool access." - ), - "has_tools": False, - "default_policy": "allow", - "risk_note": ( - "This agent has no tool access. Guardrail changes have no effect." - ), - }, - { - "id": "aitl_reviewer", - "name": "AITL Reviewer", - "description": ( - "AI reviewer that evaluates tool calls for safety. Uses one " - "internal decision tool (submit_decision)." - ), - "has_tools": True, - "default_policy": "allow", - "risk_note": ( - "The AITL reviewer IS the guardrail. Restricting it will " - "prevent it from functioning and break AITL-based approvals." - ), - }, - { - "id": "realtime", - "name": "Realtime Voice Agent", - "description": ( - "Bridges the Realtime voice model to the Copilot SDK agent. " - "Spawns one-shot sessions to execute tool-based tasks requested " - "via voice calls." - ), - "has_tools": True, - "default_policy": "background", - "risk_note": ( - "Changing the policy for the realtime agent may cause voice " - "call tool invocations to hang or be blocked." - ), - }, +from .guardrails_bulk import ( + apply_model_defaults_to_config, + apply_preset_to_config, + set_all_strategies_on_config, ) -# Set of agent context IDs for fast lookup in resolve_action fallback. -_BACKGROUND_AGENT_IDS: frozenset[str] = frozenset( - a["id"] for a in BACKGROUND_AGENTS +# Re-export public symbols so existing imports keep working. +from .guardrails_models import GuardrailRule, GuardrailsConfig, _VALID_STRATEGIES +from .guardrails_presets import ( + PRESET_BALANCED, + PRESET_PERMISSIVE, + PRESET_RESTRICTIVE, + _ALL_PRESET_TOOL_IDS, + _build_preset_policies, + list_background_agents, + list_presets, +) +from .guardrails_risk import ( + _MODEL_TIERS, + _risk_of, + get_model_tier, + get_preset_for_model, + list_model_tiers, ) +logger = logging.getLogger(__name__) -def list_background_agents() -> list[dict[str, Any]]: - """Return metadata for all background agents.""" - return list(BACKGROUND_AGENTS) - -# ── Model tiers ────────────────────────────────────────────────────────── -# Tier 1 (cautious): large frontier models -- most access, highest risk posture -# Tier 2 (standard): capable mid-range models -# Tier 3 (safe): smaller / older models -- least access, lowest risk posture - -_MODEL_TIERS: dict[str, int] = { - # Tier 1 -- cautious (most permissive, highest risk) - "gpt-5.3-codex": 1, - "claude-opus-4.6": 1, - "claude-opus-4.6-fast": 1, - # Tier 2 -- standard - "claude-sonnet-4.6": 2, - "gpt-5.2": 2, - "gemini-3-pro-preview": 2, - # Tier 3 -- safe (most restrictive, lowest risk) - "gpt-5-mini": 3, - "gpt-4.1": 3, -} - -_DEFAULT_TIER = 3 # Unknown models get the most restrictive tier - -# SDK tool categories used by presets -_FILE_TOOLS = frozenset({"create", "edit", "view", "grep", "glob"}) -_TERMINAL_TOOLS = frozenset({"run", "bash"}) - -# ── MCP / tool risk classification ────────────────────────────────────── -# Risk levels: low (read-only / public), medium (browser / scheduling), -# high (code repos, infra, phone calls). - -_MCP_RISK: dict[str, str] = { - "mcp:microsoft-learn": "low", # read-only public docs - "mcp:playwright": "medium", # browser automation, can navigate sites - "mcp:github-mcp-server": "high", # create repos, PRs, push code - "mcp:azure-mcp-server": "high", # create/delete Azure resources -} - -_SKILL_RISK: dict[str, str] = { - "skill:daily-briefing": "low", # read-only from local memory - "skill:wiki-search": "low", # read-only, public API - "skill:wiki-summary": "low", - "skill:wiki-deep-dive": "low", - "skill:gh-status-check": "low", # read-only, public API - "skill:gh-incidents": "low", - "skill:gh-maintenance": "low", - "skill:web-search": "medium", # browser-based - "skill:summarize-url": "medium", # browser-based - "skill:note-taking": "medium", # filesystem writes - "skill:daily-rollover": "medium", # M365 reads + file writes - "skill:end-day": "medium", - "skill:weekly-review": "medium", - "skill:monthly-review": "medium", - "skill:setup-foundry": "high", # provisions Azure infra - "skill:foundry-agent-chat": "high", # creates cloud agents - "skill:foundry-code-interpreter": "high", - "skill:setup-workiq": "medium", - "skill:setup-wikipedia": "low", -} - -_CUSTOM_TOOL_RISK: dict[str, str] = { - "schedule_task": "medium", - "cancel_task": "medium", - "list_scheduled_tasks": "low", - "make_voice_call": "high", - "search_memories_tool": "low", - "send_adaptive_card": "low", - "send_hero_card": "low", - "send_thumbnail_card": "low", - "send_card_carousel": "low", -} - - -def _risk_of(tool_id: str) -> str: - """Return the risk level for any tool/MCP/skill id.""" - if tool_id in _MCP_RISK: - return _MCP_RISK[tool_id] - if tool_id in _SKILL_RISK: - return _SKILL_RISK[tool_id] - if tool_id in _CUSTOM_TOOL_RISK: - return _CUSTOM_TOOL_RISK[tool_id] - # SDK tools - if tool_id in ("view", "grep", "glob"): - return "low" - if tool_id in ("create", "edit"): - return "medium" - if tool_id in ("run", "bash"): - return "high" - # Unknown MCP or skill -- default to high for safety - if tool_id.startswith("mcp:") or tool_id.startswith("skill:"): - return "high" - return "medium" - - -# ── Preset definitions ────────────────────────────────────────────────── - -PRESET_MINIMAL = "minimal" -PRESET_SUPERVISED = "supervised" -PRESET_RESTRICTIVE = "restrictive" -PRESET_BALANCED = "balanced" -PRESET_PERMISSIVE = "permissive" - -_TIER_TO_PRESET: dict[int, str] = { - 1: PRESET_PERMISSIVE, - 2: PRESET_BALANCED, - 3: PRESET_RESTRICTIVE, -} - -# Cross-reference: (selected_preset, model_tier) -> effective preset for model-column -# policies. This ensures that switching presets actually changes per-model rules -# while still respecting each model's inherent safety tier. -_EFFECTIVE_MODEL_PRESET: dict[tuple[str, int], str] = { - # Permissive preset: strong/standard models get permissive, cautious gets balanced - (PRESET_PERMISSIVE, 1): PRESET_PERMISSIVE, - (PRESET_PERMISSIVE, 2): PRESET_PERMISSIVE, - (PRESET_PERMISSIVE, 3): PRESET_BALANCED, - # Balanced preset: strong gets permissive, standard balanced, cautious restrictive - (PRESET_BALANCED, 1): PRESET_PERMISSIVE, - (PRESET_BALANCED, 2): PRESET_BALANCED, - (PRESET_BALANCED, 3): PRESET_RESTRICTIVE, - # Restrictive preset: strong gets balanced, standard/cautious get restrictive - (PRESET_RESTRICTIVE, 1): PRESET_BALANCED, - (PRESET_RESTRICTIVE, 2): PRESET_RESTRICTIVE, - (PRESET_RESTRICTIVE, 3): PRESET_RESTRICTIVE, -} - -# Strategy lookup: (preset, context, risk) -> strategy -# Rows: risk low / medium / high -# Columns: interactive / background - -_PRESET_MATRIX: dict[str, dict[str, dict[str, str]]] = { - PRESET_PERMISSIVE: { - "interactive": {"low": "filter", "medium": "filter", "high": "filter"}, - "background": {"low": "filter", "medium": "filter", "high": "hitl"}, - }, - PRESET_BALANCED: { - "interactive": {"low": "filter", "medium": "filter", "high": "hitl"}, - "background": {"low": "filter", "medium": "hitl", "high": "deny"}, - }, - PRESET_RESTRICTIVE: { - "interactive": {"low": "filter", "medium": "hitl", "high": "hitl"}, - "background": {"low": "filter", "medium": "deny", "high": "deny"}, - }, -} - -# Per-preset tool overrides applied *after* the risk matrix. -# Format: {preset: {context: {tool_id: strategy}}} -_PRESET_OVERRIDES: dict[str, dict[str, dict[str, str]]] = { - PRESET_BALANCED: { - "background": { - "create": "filter", - "edit": "filter", - }, - }, -} - -# Every tool/MCP/skill that presets should populate explicitly. -_ALL_PRESET_TOOL_IDS: list[str] = [ - # SDK - "create", "edit", "view", "grep", "glob", "run", "bash", - # Custom agent tools - "schedule_task", "cancel_task", "list_scheduled_tasks", "make_voice_call", - "send_adaptive_card", "send_hero_card", "send_thumbnail_card", "send_card_carousel", - "search_memories_tool", - # MCP - "mcp:microsoft-learn", "mcp:playwright", "mcp:github-mcp-server", "mcp:azure-mcp-server", - # Skills (builtin) - "skill:web-search", "skill:summarize-url", "skill:note-taking", "skill:daily-briefing", -] - - -def _build_preset_policies(preset: str) -> dict[str, Any]: - """Return context_defaults and tool_policies for a given preset name. - - Uses the ``_PRESET_MATRIX`` to map (preset, context, risk) -> strategy - for every known tool/MCP/skill. - """ - matrix = _PRESET_MATRIX.get(preset, _PRESET_MATRIX[PRESET_RESTRICTIVE]) - overrides = _PRESET_OVERRIDES.get(preset, {}) - policies: dict[str, dict[str, str]] = {"interactive": {}, "background": {}} - for tool_id in _ALL_PRESET_TOOL_IDS: - risk = _risk_of(tool_id) - for ctx in ("interactive", "background"): - policies[ctx][tool_id] = matrix[ctx][risk] - # Apply per-tool overrides after the matrix - for ctx, tool_map in overrides.items(): - for tool_id, strategy in tool_map.items(): - policies[ctx][tool_id] = strategy - # Context-level defaults (for tools not explicitly listed) - ctx_defaults = { - ctx: matrix[ctx]["medium"] for ctx in ("interactive", "background") - } - return { - "context_defaults": ctx_defaults, - "tool_policies": policies, - } - - -def get_model_tier(model: str) -> int: - """Return the security tier for a model (1=cautious, 2=standard, 3=safe).""" - return _MODEL_TIERS.get(model, _DEFAULT_TIER) - - -def get_preset_for_model(model: str) -> str: - """Return the recommended preset name for a model.""" - return _TIER_TO_PRESET.get(get_model_tier(model), PRESET_RESTRICTIVE) - - -def list_model_tiers() -> list[dict[str, Any]]: - """Return all known models with their tier and recommended preset.""" - result: list[dict[str, Any]] = [] - _TIER_LABELS = {1: "Strong", 2: "Standard", 3: "Cautious"} - for model, tier in sorted(_MODEL_TIERS.items(), key=lambda x: (x[1], x[0])): - result.append({ - "model": model, - "tier": tier, - "tier_label": _TIER_LABELS.get(tier, "Unknown"), - "preset": get_preset_for_model(model), - }) - return result - - -def list_presets() -> list[dict[str, Any]]: - """Return metadata for all available presets.""" - return [ - { - "id": PRESET_RESTRICTIVE, - "name": "Restrictive", - "description": ( - "For smaller or older models. Read-only tools allowed; " - "file edits and browser require HITL in interactive; " - "terminal, GitHub, Azure, and all MCP denied in background." - ), - "tier": 3, - "recommended_for": sorted( - m for m, t in _MODEL_TIERS.items() if t == 3 - ), - }, - { - "id": PRESET_BALANCED, - "name": "Balanced", - "description": ( - "For standard models. Low-risk tools allowed everywhere; " - "terminal and GitHub/Azure require HITL in interactive; " - "browser and schedules HITL in background; high-risk denied " - "in background. MS Learn allowed." - ), - "tier": 2, - "recommended_for": sorted( - m for m, t in _MODEL_TIERS.items() if t == 2 - ), - }, - { - "id": PRESET_PERMISSIVE, - "name": "Permissive", - "description": ( - "For strong frontier models. All tools allowed in interactive. " - "Terminal, GitHub, Azure still require HITL in background. " - "MS Learn, file operations, and browser allowed everywhere." - ), - "tier": 1, - "recommended_for": sorted( - m for m, t in _MODEL_TIERS.items() if t == 1 - ), - }, - ] - - -# Restrictiveness ranking for merging model policies across contexts. -# Higher rank = more restrictive. -_STRATEGY_RANK: dict[str, int] = { - "allow": 0, - "filter": 1, - "aitl": 2, - "hitl": 3, - "pitl": 4, - "ask": 4, - "deny": 5, -} - - -def _strategy_rank(strategy: str) -> int: - return _STRATEGY_RANK.get(strategy, 3) - - -@dataclass -class GuardrailsConfig: - """Top-level guardrails configuration.""" - - hitl_enabled: bool = False - default_action: str = "allow" # "allow" | "deny" | "hitl" | "pitl" | "aitl" | "filter" - default_channel: str = "chat" # "chat" | "phone" - phone_number: str = "" # E.164 number for phone verification - aitl_model: str = "gpt-4.1" # Model used by the AITL reviewer agent - aitl_spotlighting: bool = True # Spotlight untrusted content in AITL prompts - filter_mode: str = "prompt_shields" # always "prompt_shields" - content_safety_endpoint: str = "" # Azure Content Safety endpoint URL - content_safety_key: str = "" # Azure Content Safety API key - rules: list[GuardrailRule] = field(default_factory=list) - # Policy matrix fields (frontend-driven) - context_defaults: dict[str, str] = field(default_factory=dict) - tool_policies: dict[str, dict[str, str]] = field(default_factory=dict) - # Model-specific columns: user-defined model identifiers - model_columns: list[str] = field(default_factory=list) - # Model-scoped policies: model -> context -> tool -> strategy - model_policies: dict[str, dict[str, dict[str, str]]] = field(default_factory=dict) +_instance: GuardrailsConfigStore | None = None class GuardrailsConfigStore: @@ -572,10 +166,6 @@ def remove_tool_policy(self, context: str, tool_id: str) -> bool: return True return False - # ------------------------------------------------------------------ - # Model columns - # ------------------------------------------------------------------ - def add_model_column(self, model: str) -> None: if model not in self._config.model_columns: self._config.model_columns.append(model) @@ -611,74 +201,14 @@ def remove_model_policy( return True return False - # ------------------------------------------------------------------ - # Presets - # ------------------------------------------------------------------ - def apply_preset(self, preset: str, *, auto_models: bool = True) -> None: - """Apply a named preset to context_defaults and tool_policies. - - This overwrites the existing context_defaults and tool_policies. - When *auto_models* is ``True`` (default), the preset's recommended - models are added as model columns with tier-appropriate policies. - All existing model columns are also refreshed to reflect the new - preset's risk posture. - """ - valid = {PRESET_RESTRICTIVE, PRESET_BALANCED, PRESET_PERMISSIVE} - if preset not in valid: - raise ValueError("preset must be one of: %s" % ", ".join(sorted(valid))) - policies = _build_preset_policies(preset) - self._config.context_defaults = policies["context_defaults"] - self._config.tool_policies = policies["tool_policies"] - self._config.hitl_enabled = True - if auto_models: - # Add recommended models for this preset tier as model columns - preset_meta = next((p for p in list_presets() if p["id"] == preset), None) - if preset_meta: - new_models = [ - m for m in preset_meta["recommended_for"] - if m not in self._config.model_columns - ] - if new_models: - self.apply_model_defaults(new_models, preset=preset) - # Refresh ALL existing model columns with the new preset's posture - if self._config.model_columns: - self.apply_model_defaults(preset=preset) + """Apply a named preset to context_defaults and tool_policies.""" + apply_preset_to_config(self._config, preset, auto_models=auto_models) self._save() def set_all_strategies(self, strategy: str) -> None: - """Set every tool policy and context default to *strategy*. - - This is a bulk operation: all tools in ``_ALL_PRESET_TOOL_IDS`` - across interactive and background contexts are set to the given - strategy, and both context defaults are also set. All known - models from ``_MODEL_TIERS`` are added as model columns with - the same strategy applied to every tool across both contexts. - Guardrails are enabled. - """ - if strategy not in _VALID_STRATEGIES: - raise ValueError( - "strategy must be one of: %s" % ", ".join(sorted(_VALID_STRATEGIES)) - ) - policies: dict[str, dict[str, str]] = {"interactive": {}, "background": {}} - for tool_id in _ALL_PRESET_TOOL_IDS: - for ctx in ("interactive", "background"): - policies[ctx][tool_id] = strategy - self._config.context_defaults = { - "interactive": strategy, - "background": strategy, - } - self._config.tool_policies = policies - # Populate all known models with the same strategy - self._config.model_columns = sorted(_MODEL_TIERS.keys()) - model_policies: dict[str, dict[str, dict[str, str]]] = {} - for model in self._config.model_columns: - per_ctx: dict[str, dict[str, str]] = {} - for ctx in ("interactive", "background"): - per_ctx[ctx] = {tool_id: strategy for tool_id in _ALL_PRESET_TOOL_IDS} - model_policies[model] = per_ctx - self._config.model_policies = model_policies - self._config.hitl_enabled = True + """Set every tool policy and context default to *strategy*.""" + set_all_strategies_on_config(self._config, strategy) self._save() def apply_model_defaults( @@ -687,41 +217,8 @@ def apply_model_defaults( *, preset: str | None = None, ) -> None: - """Auto-populate model columns with tier-appropriate policies. - - For each model, determines the effective preset via the - ``_EFFECTIVE_MODEL_PRESET`` cross-reference of *preset* (the - user-selected risk posture) and the model's inherent tier. - Each model gets separate per-context policies (interactive - and background). - - If *models* is ``None``, uses the existing ``model_columns``. - If *preset* is ``None``, falls back to the model's own tier - preset. - """ - target_models = models if models is not None else list(self._config.model_columns) - for model in target_models: - if model not in self._config.model_columns: - self._config.model_columns.append(model) - tier = get_model_tier(model) - if preset: - effective = _EFFECTIVE_MODEL_PRESET.get( - (preset, tier), - get_preset_for_model(model), - ) - else: - effective = get_preset_for_model(model) - matrix = _PRESET_MATRIX.get(effective, _PRESET_MATRIX[PRESET_RESTRICTIVE]) - overrides = _PRESET_OVERRIDES.get(effective, {}) - per_ctx: dict[str, dict[str, str]] = {} - for ctx in ("interactive", "background"): - ctx_overrides = overrides.get(ctx, {}) - ctx_policies: dict[str, str] = {} - for tool_id in _ALL_PRESET_TOOL_IDS: - risk = _risk_of(tool_id) - ctx_policies[tool_id] = ctx_overrides.get(tool_id, matrix[ctx][risk]) - per_ctx[ctx] = ctx_policies - self._config.model_policies[model] = per_ctx + """Auto-populate model columns with tier-appropriate policies.""" + apply_model_defaults_to_config(self._config, models, preset=preset) self._save() def add_rule( @@ -917,10 +414,6 @@ def _load(self) -> None: except Exception as exc: logger.warning("Failed to load guardrails config from %s: %s", self._path, exc) - # ------------------------------------------------------------------ - # Policy YAML management - # ------------------------------------------------------------------ - @property def policy_path(self) -> Path: """Path to the generated policy YAML file.""" diff --git a/app/runtime/state/infra_config.py b/app/runtime/state/infra_config.py index 49d3735..7ee2a17 100644 --- a/app/runtime/state/infra_config.py +++ b/app/runtime/state/infra_config.py @@ -2,13 +2,11 @@ from __future__ import annotations -import json import logging from dataclasses import asdict, dataclass, field -from pathlib import Path from typing import Any -from ..config.settings import cfg +from ._base import BaseConfigStore logger = logging.getLogger(__name__) @@ -48,16 +46,30 @@ class ChannelsConfig: voice_call: VoiceCallConfig = field(default_factory=VoiceCallConfig) -class InfraConfigStore: +@dataclass +class InfraConfig: + """Top-level config dataclass wrapping bot and channel configs.""" + + bot: BotInfraConfig = field(default_factory=BotInfraConfig) + channels: ChannelsConfig = field(default_factory=ChannelsConfig) + + +class InfraConfigStore(BaseConfigStore[InfraConfig]): """Persists infrastructure configuration to ``infra.json``.""" - _SECRET_FIELDS = {"token", "acs_connection_string", "azure_openai_api_key"} + _config_type = InfraConfig + _default_filename = "infra.json" + _log_label = "infra config" + _SECRET_FIELDS = frozenset({"token", "acs_connection_string", "azure_openai_api_key"}) + _secret_prefix = "infra-" + + @property + def bot(self) -> BotInfraConfig: + return self._config.bot - def __init__(self, path: Path | None = None) -> None: - self._path = path or (cfg.data_dir / "infra.json") - self.bot = BotInfraConfig() - self.channels = ChannelsConfig() - self._load() + @property + def channels(self) -> ChannelsConfig: + return self._config.channels @property def bot_configured(self) -> bool: @@ -71,108 +83,75 @@ def telegram_configured(self) -> bool: def voice_call_configured(self) -> bool: return bool(self.channels.voice_call.acs_connection_string) - def _load(self) -> None: - if not self._path.exists(): - return - try: - data = json.loads(self._path.read_text()) - except (json.JSONDecodeError, OSError): - return - bot_data = data.get("bot", {}) + def _apply_raw(self, raw: dict[str, Any]) -> None: + bot_data = raw.get("bot", {}) for k, v in bot_data.items(): - if hasattr(self.bot, k): + if hasattr(self._config.bot, k): try: - setattr(self.bot, k, self._resolve_secret(v)) + setattr(self._config.bot, k, self._resolve_secret(v)) except Exception: logger.warning("Failed to resolve bot.%s -- skipping", k, exc_info=True) - tg_data = data.get("channels", {}).get("telegram", {}) + tg_data = raw.get("channels", {}).get("telegram", {}) for k, v in tg_data.items(): - if hasattr(self.channels.telegram, k): + if hasattr(self._config.channels.telegram, k): try: - setattr(self.channels.telegram, k, self._resolve_secret(v)) + setattr(self._config.channels.telegram, k, self._resolve_secret(v)) except Exception: logger.warning("Failed to resolve telegram.%s -- skipping", k, exc_info=True) - vc_data = data.get("channels", {}).get("voice_call", {}) + vc_data = raw.get("channels", {}).get("voice_call", {}) for k, v in vc_data.items(): - if hasattr(self.channels.voice_call, k): + if hasattr(self._config.channels.voice_call, k): try: - setattr(self.channels.voice_call, k, self._resolve_secret(v)) + setattr(self._config.channels.voice_call, k, self._resolve_secret(v)) except Exception: logger.warning("Failed to resolve voice_call.%s -- skipping", k, exc_info=True) - def _save(self) -> None: - data = { - "bot": asdict(self.bot), + def _save_data(self) -> dict[str, Any]: + return { + "bot": asdict(self._config.bot), "channels": { - "telegram": self._store_secrets(asdict(self.channels.telegram)), - "voice_call": self._store_secrets(asdict(self.channels.voice_call)), + "telegram": self._store_secrets(asdict(self._config.channels.telegram)), + "voice_call": self._store_secrets(asdict(self._config.channels.voice_call)), }, } - self._path.parent.mkdir(parents=True, exist_ok=True) - self._path.write_text(json.dumps(data, indent=2) + "\n") def save_bot(self, **kwargs: str) -> None: for k, v in kwargs.items(): - if hasattr(self.bot, k): - setattr(self.bot, k, v) + if hasattr(self._config.bot, k): + setattr(self._config.bot, k, v) self._save() def save_telegram(self, **kwargs: str) -> None: for k, v in kwargs.items(): - if hasattr(self.channels.telegram, k): - setattr(self.channels.telegram, k, v) + if hasattr(self._config.channels.telegram, k): + setattr(self._config.channels.telegram, k, v) self._save() def clear_telegram(self) -> None: - self.channels.telegram = TelegramChannelConfig() + self._config.channels.telegram = TelegramChannelConfig() self._save() def save_voice_call(self, **kwargs: str) -> None: for k, v in kwargs.items(): - if hasattr(self.channels.voice_call, k): - setattr(self.channels.voice_call, k, v) + if hasattr(self._config.channels.voice_call, k): + setattr(self._config.channels.voice_call, k, v) self._save() def clear_voice_call(self) -> None: - self.channels.voice_call = VoiceCallConfig() + self._config.channels.voice_call = VoiceCallConfig() self._save() def to_safe_dict(self) -> dict[str, Any]: - data = { - "bot": asdict(self.bot), + return { + "bot": asdict(self._config.bot), "channels": { - "telegram": self._mask_secrets(asdict(self.channels.telegram)), - "voice_call": self._mask_secrets(asdict(self.channels.voice_call)), + "telegram": self._mask_secrets(asdict(self._config.channels.telegram)), + "voice_call": self._mask_secrets(asdict(self._config.channels.voice_call)), }, } - return data def _mask_secrets(self, d: dict[str, Any]) -> dict[str, Any]: return { k: ("****" if k in self._SECRET_FIELDS and v else v) for k, v in d.items() } - - def _store_secrets(self, d: dict[str, Any]) -> dict[str, Any]: - from ..services.keyvault import kv, env_key_to_secret_name, is_kv_ref - - result = dict(d) - if not kv.enabled: - return result - for k in self._SECRET_FIELDS: - val = result.get(k, "") - if val and not is_kv_ref(val): - try: - ref = kv.store(env_key_to_secret_name(f"infra-{k}"), val) - result[k] = ref - except Exception as exc: - logger.warning("Failed to store secret %s in KV: %s", k, exc) - return result - - @staticmethod - def _resolve_secret(value: Any) -> Any: - if not isinstance(value, str): - return value - from ..services.keyvault import resolve_if_kv_ref - - return resolve_if_kv_ref(value) diff --git a/app/runtime/state/mcp_config.py b/app/runtime/state/mcp_config.py index d053be1..717ed77 100644 --- a/app/runtime/state/mcp_config.py +++ b/app/runtime/state/mcp_config.py @@ -195,7 +195,9 @@ def _load(self) -> None: if dirty: self._save() except Exception as exc: - logger.warning("Failed to load MCP config from %s: %s", self._path, exc) + logger.warning( + "Failed to load MCP config from %s: %s", self._path, exc, exc_info=True, + ) def _save(self) -> None: self._path.parent.mkdir(parents=True, exist_ok=True) diff --git a/app/runtime/state/memory.py b/app/runtime/state/memory.py index cd90e71..f74d05d 100644 --- a/app/runtime/state/memory.py +++ b/app/runtime/state/memory.py @@ -152,7 +152,7 @@ def _format_transcript(entries: list[_ChatEntry]) -> str: @staticmethod def _build_system_message() -> str: - from .profile import _profile_path, _usage_path as _skill_usage_path + from .profile import profile_path, _usage_path as _skill_usage_path template = (_TEMPLATES_DIR / "memory_prompt.md").read_text() @@ -163,7 +163,7 @@ def _build_system_message() -> str: return template.format( memory_daily_dir=cfg.memory_daily_dir, memory_topics_dir=cfg.memory_topics_dir, - profile_path=_profile_path(), + profile_path=profile_path(), skill_usage_path=_skill_usage_path(), suggestions_path=cfg.data_dir / "suggestions.txt", data_dir=cfg.data_dir, @@ -173,8 +173,6 @@ def _build_system_message() -> str: @staticmethod def _build_proactive_section() -> str: - from .session_store import SessionStore - proactive_template = (_TEMPLATES_DIR / "proactive_prompt_section.md").read_text() store = get_proactive_store() @@ -321,14 +319,17 @@ async def _process_proactive_followup(self) -> None: hours_since = store.hours_since_last_sent() if hours_since is not None and hours_since < prefs.min_gap_hours: - logger.info("Proactive gap too short (%.1fh < %dh), skipping.", hours_since, prefs.min_gap_hours) + logger.info( + "Proactive gap too short (%.1fh < %dh), skipping.", + hours_since, prefs.min_gap_hours, + ) return store.schedule_followup(message=message, deliver_at=deliver_at, context=context) self._last_proactive_scheduled = True logger.info("Proactive follow-up scheduled: %s at %s", message[:50], deliver_at) except (json.JSONDecodeError, OSError) as exc: - logger.warning("Failed to process proactive follow-up: %s", exc) + logger.warning("Failed to process proactive follow-up: %s", exc, exc_info=True) try: followup_path.unlink(missing_ok=True) except OSError: @@ -361,7 +362,7 @@ def _process_proactive_reaction() -> None: store.update_preferences(avoided_topics=list(prefs.avoided_topics) + [detail]) logger.info("Added avoided topic from negative reaction: %s", detail) except (json.JSONDecodeError, OSError) as exc: - logger.warning("Failed to process proactive reaction: %s", exc) + logger.warning("Failed to process proactive reaction: %s", exc, exc_info=True) try: reaction_path.unlink(missing_ok=True) except OSError: diff --git a/app/runtime/state/monitoring_config.py b/app/runtime/state/monitoring_config.py index 8d0fb57..627e59a 100644 --- a/app/runtime/state/monitoring_config.py +++ b/app/runtime/state/monitoring_config.py @@ -2,15 +2,10 @@ from __future__ import annotations -import json -import logging from dataclasses import asdict, dataclass, field -from pathlib import Path from typing import Any -from ..config.settings import cfg - -logger = logging.getLogger(__name__) +from ._base import BaseConfigStore def _url_encode_slashes(path: str) -> str: @@ -34,21 +29,12 @@ class MonitoringConfig: subscription_id: str = "" -class MonitoringConfigStore: +class MonitoringConfigStore(BaseConfigStore[MonitoringConfig]): """JSON-file-backed monitoring / OTel configuration.""" - def __init__(self, path: Path | None = None) -> None: - self._path = path or (cfg.data_dir / "monitoring.json") - self._config = MonitoringConfig() - self._load() - - @property - def path(self) -> Path: - return self._path - - @property - def config(self) -> MonitoringConfig: - return self._config + _config_type = MonitoringConfig + _default_filename = "monitoring.json" + _log_label = "monitoring config" @property def enabled(self) -> bool: @@ -157,27 +143,4 @@ def to_dict_full(self) -> dict[str, Any]: """Return the full config including secrets -- internal use only.""" return asdict(self._config) - def _load(self) -> None: - if not self._path.exists(): - return - try: - raw = json.loads(self._path.read_text()) - self._config = MonitoringConfig( - enabled=raw.get("enabled", False), - connection_string=raw.get("connection_string", ""), - sampling_ratio=raw.get("sampling_ratio", 1.0), - enable_live_metrics=raw.get("enable_live_metrics", False), - instrumentation_options=raw.get("instrumentation_options", {}), - provisioned=raw.get("provisioned", False), - app_insights_name=raw.get("app_insights_name", ""), - workspace_name=raw.get("workspace_name", ""), - resource_group=raw.get("resource_group", ""), - location=raw.get("location", ""), - subscription_id=raw.get("subscription_id", ""), - ) - except Exception as exc: - logger.warning("Failed to load monitoring config from %s: %s", self._path, exc) - def _save(self) -> None: - self._path.parent.mkdir(parents=True, exist_ok=True) - self._path.write_text(json.dumps(asdict(self._config), indent=2) + "\n") diff --git a/app/runtime/state/plugin_config.py b/app/runtime/state/plugin_config.py index 4ebcbba..0daf743 100644 --- a/app/runtime/state/plugin_config.py +++ b/app/runtime/state/plugin_config.py @@ -15,6 +15,12 @@ logger = logging.getLogger(__name__) +_DEFAULT_STATE: dict[str, Any] = { + "enabled": False, + "setup_completed": False, + "installed_at": None, +} + class PluginConfigStore: """JSON-file-backed plugin state store.""" @@ -29,22 +35,14 @@ def path(self) -> Path: return self._path def get_state(self, plugin_id: str) -> dict[str, Any]: - return self._plugins.get(plugin_id, { - "enabled": False, - "setup_completed": False, - "installed_at": None, - }) + return self._plugins.get(plugin_id, dict(_DEFAULT_STATE)) def list_states(self) -> dict[str, dict[str, Any]]: return dict(self._plugins) def set_enabled(self, plugin_id: str, enabled: bool) -> None: if plugin_id not in self._plugins: - self._plugins[plugin_id] = { - "enabled": False, - "setup_completed": False, - "installed_at": None, - } + self._plugins[plugin_id] = dict(_DEFAULT_STATE) self._plugins[plugin_id]["enabled"] = enabled if enabled and not self._plugins[plugin_id].get("installed_at"): self._plugins[plugin_id]["installed_at"] = datetime.now(UTC).isoformat() @@ -52,20 +50,12 @@ def set_enabled(self, plugin_id: str, enabled: bool) -> None: def mark_setup_completed(self, plugin_id: str) -> None: if plugin_id not in self._plugins: - self._plugins[plugin_id] = { - "enabled": False, - "setup_completed": False, - "installed_at": None, - } + self._plugins[plugin_id] = dict(_DEFAULT_STATE) self._plugins[plugin_id]["setup_completed"] = True self._save() def reset(self, plugin_id: str) -> None: - self._plugins[plugin_id] = { - "enabled": False, - "setup_completed": False, - "installed_at": None, - } + self._plugins[plugin_id] = dict(_DEFAULT_STATE) self._save() def _load(self) -> None: @@ -75,7 +65,9 @@ def _load(self) -> None: raw = json.loads(self._path.read_text()) self._plugins = raw.get("plugins", {}) except Exception as exc: - logger.warning("Failed to load plugin config from %s: %s", self._path, exc) + logger.warning( + "Failed to load plugin config from %s: %s", self._path, exc, exc_info=True, + ) def _save(self) -> None: self._path.parent.mkdir(parents=True, exist_ok=True) diff --git a/app/runtime/state/proactive.py b/app/runtime/state/proactive.py index a41185d..185dfcc 100644 --- a/app/runtime/state/proactive.py +++ b/app/runtime/state/proactive.py @@ -63,7 +63,7 @@ def _load(self) -> None: try: self._data = json.loads(self._path.read_text()) except (json.JSONDecodeError, OSError) as exc: - logger.warning("Failed to load proactive state: %s", exc) + logger.warning("Failed to load proactive state: %s", exc, exc_info=True) self._data = {} env_default = cfg.proactive_enabled if not self._path.exists() else False self._data.setdefault("enabled", env_default) diff --git a/app/runtime/state/profile.py b/app/runtime/state/profile.py index 53f2c76..355b85c 100644 --- a/app/runtime/state/profile.py +++ b/app/runtime/state/profile.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import logging import time from pathlib import Path from typing import Any @@ -11,8 +10,6 @@ from ..config.settings import cfg from ..util.singletons import register_singleton -logger = logging.getLogger(__name__) - _DEFAULT_PROFILE: dict[str, Any] = { "name": "polyclaw", "emoji": "", @@ -26,6 +23,11 @@ def _profile_path() -> Path: return cfg.data_dir / "agent_profile.json" +def profile_path() -> Path: + """Return the path to the agent profile JSON file.""" + return _profile_path() + + def _usage_path() -> Path: return cfg.data_dir / "skill_usage.json" diff --git a/app/runtime/state/sandbox_config.py b/app/runtime/state/sandbox_config.py index 0b4f5b2..4113bae 100644 --- a/app/runtime/state/sandbox_config.py +++ b/app/runtime/state/sandbox_config.py @@ -2,15 +2,10 @@ from __future__ import annotations -import json -import logging -from dataclasses import asdict, dataclass, field -from pathlib import Path +from dataclasses import dataclass, field from typing import Any -from ..config.settings import cfg - -logger = logging.getLogger(__name__) +from ._base import BaseConfigStore DEFAULT_WHITELIST: list[str] = [ "media", "memory", "notes", "sessions", "skills", @@ -38,21 +33,12 @@ class SandboxConfig: pool_id: str = "" -class SandboxConfigStore: +class SandboxConfigStore(BaseConfigStore[SandboxConfig]): """JSON-file-backed sandbox configuration.""" - def __init__(self, path: Path | None = None) -> None: - self._path = path or (cfg.data_dir / "sandbox.json") - self._config = SandboxConfig() - self._load() - - @property - def path(self) -> Path: - return self._path - - @property - def config(self) -> SandboxConfig: - return self._config + _config_type = SandboxConfig + _default_filename = "sandbox.json" + _log_label = "sandbox config" @property def enabled(self) -> bool: @@ -155,27 +141,4 @@ def update(self, **kwargs: Any) -> None: setattr(self._config, k, v) self._save() - def to_dict(self) -> dict[str, Any]: - return asdict(self._config) - - def _load(self) -> None: - if not self._path.exists(): - return - try: - raw = json.loads(self._path.read_text()) - self._config = SandboxConfig( - enabled=raw.get("enabled", False), - sync_data=raw.get("sync_data", True), - session_pool_endpoint=raw.get("session_pool_endpoint", ""), - whitelist=raw.get("whitelist", list(DEFAULT_WHITELIST)), - resource_group=raw.get("resource_group", ""), - location=raw.get("location", ""), - pool_name=raw.get("pool_name", ""), - pool_id=raw.get("pool_id", ""), - ) - except Exception as exc: - logger.warning("Failed to load sandbox config from %s: %s", self._path, exc) - - def _save(self) -> None: - self._path.parent.mkdir(parents=True, exist_ok=True) - self._path.write_text(json.dumps(asdict(self._config), indent=2) + "\n") + diff --git a/app/runtime/state/tool_activity_models.py b/app/runtime/state/tool_activity_models.py new file mode 100644 index 0000000..373f5e1 --- /dev/null +++ b/app/runtime/state/tool_activity_models.py @@ -0,0 +1,91 @@ +"""Data models and risk-scoring helpers for tool activity tracking.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class ToolActivityEntry: + """A single recorded tool invocation.""" + + id: str = "" + session_id: str = "" + tool: str = "" + call_id: str = "" + category: str = "" # sdk | custom | mcp | skill + arguments: str = "" + result: str = "" + status: str = "" # started | completed | denied | error + timestamp: float = 0.0 + duration_ms: float | None = None + flagged: bool = False + flag_reason: str = "" + risk_score: int = 0 # 0-100 computed risk score + risk_factors: list[str] = field(default_factory=list) + model: str = "" # which LLM model initiated this tool call + interaction_type: str = "" # "" | hitl | aitl | pitl | filter | deny + shield_result: str = "" # "" | clean | attack | error | not_configured + shield_detail: str = "" # human-readable detail from Content Safety API + shield_elapsed_ms: float | None = None # round-trip time for the shield call + + +_SUSPICIOUS_PATTERNS: list[tuple[str, int, str]] = [ + # (pattern, severity 1-100, description) + ("rm -rf", 90, "Recursive forced deletion"), + ("rm -r /", 100, "Root filesystem deletion"), + ("DROP TABLE", 85, "SQL table drop"), + ("DELETE FROM", 60, "SQL mass deletion"), + ("curl.*|.*sh", 80, "Remote code execution via curl"), + ("wget.*|.*sh", 80, "Remote code execution via wget"), + ("eval(", 75, "Dynamic code evaluation"), + ("exec(", 75, "Dynamic code execution"), + ("os.system", 70, "Shell command execution"), + ("subprocess", 50, "Subprocess invocation"), + ("chmod 777", 65, "World-writable permissions"), + ("passwd", 55, "Password file access"), + ("/etc/shadow", 90, "Shadow password file access"), + ("env | grep", 45, "Environment variable enumeration"), + ("printenv", 45, "Environment variable dump"), + ("base64 -d", 60, "Base64 decode (potential obfuscation)"), + (".ssh/", 70, "SSH directory access"), + ("id_rsa", 85, "SSH private key access"), + ("PRIVATE KEY", 95, "Private key exposure"), + ("API_KEY", 50, "API key in arguments"), + ("SECRET", 55, "Secret value in arguments"), + ("TOKEN", 45, "Token value in arguments"), + ("password", 50, "Password in arguments"), + ("credentials", 55, "Credentials reference"), + ("sudo ", 60, "Privilege escalation"), + ("nc -l", 70, "Netcat listener (reverse shell)"), + (">&/dev/tcp", 90, "Bash reverse shell"), + ("/dev/tcp", 85, "Network device access"), + ("mkfifo", 65, "Named pipe creation"), + ("nmap", 55, "Network scanning"), + ("sqlmap", 80, "SQL injection tool"), + (".env", 40, "Environment file access"), + ("aws configure", 50, "Cloud credential configuration"), + ("gcloud auth", 50, "Cloud credential configuration"), + ("az login", 40, "Azure CLI login"), + ("docker run", 45, "Container execution"), + ("kubectl exec", 55, "Kubernetes pod execution"), +] + + +def check_suspicious(arguments: str, result: str) -> tuple[bool, str, int, list[str]]: + """Check if a tool call looks suspicious based on arguments/result. + + Returns (flagged, primary_reason, risk_score, risk_factors). + """ + text = f"{arguments} {result}".lower() + factors: list[str] = [] + max_severity = 0 + primary_reason = "" + for pattern, severity, description in _SUSPICIOUS_PATTERNS: + if pattern.lower() in text: + factors.append(description) + if severity > max_severity: + max_severity = severity + primary_reason = f"Suspicious pattern: {pattern}" + flagged = max_severity >= 40 + return flagged, primary_reason, max_severity, factors diff --git a/app/runtime/state/tool_activity_store.py b/app/runtime/state/tool_activity_store.py index 23134b9..93f309d 100644 --- a/app/runtime/state/tool_activity_store.py +++ b/app/runtime/state/tool_activity_store.py @@ -8,102 +8,17 @@ import logging import threading import time -from dataclasses import asdict, dataclass, field +from dataclasses import asdict from pathlib import Path from typing import Any from ..config.settings import cfg from ..util.singletons import register_singleton +from .tool_activity_models import ToolActivityEntry, check_suspicious logger = logging.getLogger(__name__) -@dataclass -class ToolActivityEntry: - """A single recorded tool invocation.""" - - id: str = "" - session_id: str = "" - tool: str = "" - call_id: str = "" - category: str = "" # sdk | custom | mcp | skill - arguments: str = "" - result: str = "" - status: str = "" # started | completed | denied | error - timestamp: float = 0.0 - duration_ms: float | None = None - flagged: bool = False - flag_reason: str = "" - risk_score: int = 0 # 0-100 computed risk score - risk_factors: list[str] = field(default_factory=list) - model: str = "" # which LLM model initiated this tool call - interaction_type: str = "" # "" | hitl | aitl | pitl | filter | deny - shield_result: str = "" # "" | clean | attack | error | not_configured - shield_detail: str = "" # human-readable detail from Content Safety API - shield_elapsed_ms: float | None = None # round-trip time for the shield call - - -_SUSPICIOUS_PATTERNS: list[tuple[str, int, str]] = [ - # (pattern, severity 1-100, description) - ("rm -rf", 90, "Recursive forced deletion"), - ("rm -r /", 100, "Root filesystem deletion"), - ("DROP TABLE", 85, "SQL table drop"), - ("DELETE FROM", 60, "SQL mass deletion"), - ("curl.*|.*sh", 80, "Remote code execution via curl"), - ("wget.*|.*sh", 80, "Remote code execution via wget"), - ("eval(", 75, "Dynamic code evaluation"), - ("exec(", 75, "Dynamic code execution"), - ("os.system", 70, "Shell command execution"), - ("subprocess", 50, "Subprocess invocation"), - ("chmod 777", 65, "World-writable permissions"), - ("passwd", 55, "Password file access"), - ("/etc/shadow", 90, "Shadow password file access"), - ("env | grep", 45, "Environment variable enumeration"), - ("printenv", 45, "Environment variable dump"), - ("base64 -d", 60, "Base64 decode (potential obfuscation)"), - (".ssh/", 70, "SSH directory access"), - ("id_rsa", 85, "SSH private key access"), - ("PRIVATE KEY", 95, "Private key exposure"), - ("API_KEY", 50, "API key in arguments"), - ("SECRET", 55, "Secret value in arguments"), - ("TOKEN", 45, "Token value in arguments"), - ("password", 50, "Password in arguments"), - ("credentials", 55, "Credentials reference"), - ("sudo ", 60, "Privilege escalation"), - ("nc -l", 70, "Netcat listener (reverse shell)"), - (">&/dev/tcp", 90, "Bash reverse shell"), - ("/dev/tcp", 85, "Network device access"), - ("mkfifo", 65, "Named pipe creation"), - ("nmap", 55, "Network scanning"), - ("sqlmap", 80, "SQL injection tool"), - (".env", 40, "Environment file access"), - ("aws configure", 50, "Cloud credential configuration"), - ("gcloud auth", 50, "Cloud credential configuration"), - ("az login", 40, "Azure CLI login"), - ("docker run", 45, "Container execution"), - ("kubectl exec", 55, "Kubernetes pod execution"), -] - - -def _check_suspicious(arguments: str, result: str) -> tuple[bool, str, int, list[str]]: - """Check if a tool call looks suspicious based on arguments/result. - - Returns (flagged, primary_reason, risk_score, risk_factors). - """ - text = f"{arguments} {result}".lower() - factors: list[str] = [] - max_severity = 0 - primary_reason = "" - for pattern, severity, description in _SUSPICIOUS_PATTERNS: - if pattern.lower() in text: - factors.append(description) - if severity > max_severity: - max_severity = severity - primary_reason = f"Suspicious pattern: {pattern}" - flagged = max_severity >= 40 - return flagged, primary_reason, max_severity, factors - - class ToolActivityStore: """Append-only log of tool invocations for audit and review. @@ -120,6 +35,13 @@ def __init__(self, path: Path | None = None) -> None: self._counter = 0 self._load() + def _deduplicated(self) -> list[ToolActivityEntry]: + """Return entries deduplicated by id, keeping the latest version.""" + by_id: dict[str, ToolActivityEntry] = {} + for e in self._entries: + by_id[e.id] = e + return list(by_id.values()) + def _load(self) -> None: if not self._path.exists(): return @@ -139,7 +61,7 @@ def _load(self) -> None: self._counter = max(self._counter, int(entry.id.split("-")[-1] or "0")) self._entries = list(by_id.values()) except (json.JSONDecodeError, OSError) as exc: - logger.warning("[tool_activity] failed to load: %s", exc) + logger.warning("[tool_activity] failed to load: %s", exc, exc_info=True) def _next_id(self) -> str: self._counter += 1 @@ -174,7 +96,7 @@ def record_start( model=model, interaction_type=interaction_type, ) - flagged, reason, risk, factors = _check_suspicious(arguments, "") + flagged, reason, risk, factors = check_suspicious(arguments, "") entry.flagged = flagged entry.flag_reason = reason entry.risk_score = risk @@ -209,7 +131,7 @@ def record_complete( pending.result = result[:2000] if result else "" pending.status = status pending.duration_ms = (time.time() - pending.timestamp) * 1000 - flagged, reason, risk, factors = _check_suspicious(pending.arguments, result) + flagged, reason, risk, factors = check_suspicious(pending.arguments, result) if flagged and not pending.flagged: pending.flagged = True pending.flag_reason = reason @@ -264,11 +186,9 @@ def query( ) -> dict[str, Any]: """Query tool activity with filters.""" with self._lock: - # Deduplicate: keep the latest version of each entry id - by_id: dict[str, ToolActivityEntry] = {} - for e in self._entries: - by_id[e.id] = e - entries = sorted(by_id.values(), key=lambda e: e.timestamp, reverse=True) + entries = sorted( + self._deduplicated(), key=lambda e: e.timestamp, reverse=True, + ) # Apply filters if session_id: @@ -301,10 +221,7 @@ def query( def get_summary(self) -> dict[str, Any]: """Get aggregate statistics about tool activity.""" with self._lock: - by_id: dict[str, ToolActivityEntry] = {} - for e in self._entries: - by_id[e.id] = e - entries = list(by_id.values()) + entries = self._deduplicated() total = len(entries) flagged = sum(1 for e in entries if e.flagged) @@ -406,10 +323,7 @@ def get_timeline( ) -> list[dict[str, Any]]: """Return tool call counts bucketed by time interval.""" with self._lock: - by_id: dict[str, ToolActivityEntry] = {} - for e in self._entries: - by_id[e.id] = e - entries = list(by_id.values()) + entries = self._deduplicated() if not entries: return [] @@ -427,7 +341,10 @@ def get_timeline( continue bucket_ts = int(e.timestamp // bucket_secs) * bucket_secs if bucket_ts not in buckets: - buckets[bucket_ts] = {"total": 0, "flagged": 0, "sdk": 0, "mcp": 0, "custom": 0, "skill": 0} + buckets[bucket_ts] = { + "total": 0, "flagged": 0, + "sdk": 0, "mcp": 0, "custom": 0, "skill": 0, + } buckets[bucket_ts]["total"] += 1 if e.flagged: buckets[bucket_ts]["flagged"] += 1 @@ -442,10 +359,7 @@ def get_timeline( def get_session_breakdown(self) -> list[dict[str, Any]]: """Return per-session aggregation for the session-level audit view.""" with self._lock: - by_id: dict[str, ToolActivityEntry] = {} - for e in self._entries: - by_id[e.id] = e - entries = list(by_id.values()) + entries = self._deduplicated() sessions: dict[str, dict[str, Any]] = {} for e in entries: @@ -570,7 +484,7 @@ def import_from_sessions(self, session_store: object) -> int: status="completed", timestamp=msg.get("timestamp", 0), ) - flagged, reason, risk, factors = _check_suspicious(entry.arguments, entry.result) + flagged, reason, risk, factors = check_suspicious(entry.arguments, entry.result) entry.flagged = flagged entry.flag_reason = reason entry.risk_score = risk diff --git a/app/runtime/tests/test_agent_tools.py b/app/runtime/tests/test_agent_tools.py index 7eb2ee0..9f84994 100644 --- a/app/runtime/tests/test_agent_tools.py +++ b/app/runtime/tests/test_agent_tools.py @@ -150,7 +150,7 @@ def test_list_with_tasks(self): class TestMakeVoiceCallTool: - @patch("app.runtime.agent.tools.cfg") + @patch("app.runtime.agent.tools.voice.cfg") def test_no_target_number(self, mock_cfg): from app.runtime.agent.tools import make_voice_call @@ -158,8 +158,8 @@ def test_no_target_number(self, mock_cfg): result = _call_tool(make_voice_call, {"prompt": "hi"}) assert result["status"] == "error" - @patch("app.runtime.agent.tools.threading.Thread") - @patch("app.runtime.agent.tools.cfg") + @patch("app.runtime.agent.tools.voice.threading.Thread") + @patch("app.runtime.agent.tools.voice.cfg") def test_with_target_number(self, mock_cfg, mock_thread): from app.runtime.agent.tools import make_voice_call diff --git a/app/runtime/tests/test_azure_cli.py b/app/runtime/tests/test_azure_cli.py index b071401..88201dc 100644 --- a/app/runtime/tests/test_azure_cli.py +++ b/app/runtime/tests/test_azure_cli.py @@ -10,7 +10,7 @@ import pytest -from app.runtime.services.azure import AzureCLI +from app.runtime.services.cloud.azure import AzureCLI from app.runtime.util.result import Result @@ -156,7 +156,7 @@ def test_network_error(self, mock_urlopen) -> None: assert result.success is False assert "Cannot reach" in result.message - @patch("app.runtime.services.azure.sleep", return_value=None) + @patch("app.runtime.services.cloud.azure.sleep", return_value=None) @patch("urllib.request.urlopen") def test_404_not_retried(self, mock_urlopen, _mock_sleep) -> None: """A 404 means the bot doesn't exist -- it should NOT be retried.""" @@ -170,7 +170,7 @@ def test_404_not_retried(self, mock_urlopen, _mock_sleep) -> None: # Only one attempt -- no retries on 404. assert mock_urlopen.call_count == 1 - @patch("app.runtime.services.azure.sleep", return_value=None) + @patch("app.runtime.services.cloud.azure.sleep", return_value=None) @patch("urllib.request.urlopen") def test_retries_on_transient_502(self, mock_urlopen, _mock_sleep) -> None: """A transient 502 should be retried and succeed on the next attempt.""" @@ -202,7 +202,7 @@ class TestAzureCLIGetChannels: @patch.object(AzureCLI, "json") def test_no_config(self, mock_json) -> None: az = AzureCLI() - with patch("app.runtime.services.azure.cfg") as mock_cfg: + with patch("app.runtime.config.settings.cfg") as mock_cfg: mock_cfg.env = MagicMock() mock_cfg.env.read.return_value = "" result = az.get_channels() @@ -214,7 +214,7 @@ def test_with_telegram(self, mock_json) -> None: "properties": {"configuredChannels": ["webchat", "telegram"]} } az = AzureCLI() - with patch("app.runtime.services.azure.cfg") as mock_cfg: + with patch("app.runtime.config.settings.cfg") as mock_cfg: mock_cfg.env = MagicMock() mock_cfg.env.read.side_effect = lambda k: "rg" if k == "BOT_RESOURCE_GROUP" else "bot" result = az.get_channels() @@ -225,7 +225,7 @@ class TestAzureCLIUpdateEndpoint: @patch.object(AzureCLI, "json") def test_not_configured(self, mock_json) -> None: az = AzureCLI() - with patch("app.runtime.services.azure.cfg") as mock_cfg: + with patch("app.runtime.config.settings.cfg") as mock_cfg: mock_cfg.env = MagicMock() mock_cfg.env.read.return_value = "" result = az.update_endpoint("https://example.com/api/messages") diff --git a/app/runtime/tests/test_content_safety_routes.py b/app/runtime/tests/test_content_safety_routes.py index 6156854..eea9fff 100644 --- a/app/runtime/tests/test_content_safety_routes.py +++ b/app/runtime/tests/test_content_safety_routes.py @@ -9,8 +9,8 @@ from aiohttp.test_utils import TestClient, TestServer from app.runtime.server.routes.content_safety_routes import ContentSafetyRoutes -from app.runtime.services.prompt_shield import ShieldResult -from app.runtime.state.guardrails_config import GuardrailsConfigStore +from app.runtime.services.security.prompt_shield import ShieldResult +from app.runtime.state.guardrails import GuardrailsConfigStore def _build_app(routes: ContentSafetyRoutes) -> web.Application: diff --git a/app/runtime/tests/test_extract_media.py b/app/runtime/tests/test_extract_media.py index 0fb0fa3..b4b0a4f 100644 --- a/app/runtime/tests/test_extract_media.py +++ b/app/runtime/tests/test_extract_media.py @@ -1,4 +1,4 @@ -"""Tests for incoming media (extract_outgoing_attachments, download_attachment).""" +"""Tests for media extraction (extract_outgoing_attachments, download_attachment).""" from __future__ import annotations @@ -8,7 +8,8 @@ import pytest -from app.runtime.media.incoming import build_media_prompt, extract_outgoing_attachments +from app.runtime.media.incoming import build_media_prompt +from app.runtime.media.outgoing import extract_outgoing_attachments class TestExtractOutgoingAttachments: diff --git a/app/runtime/tests/test_guardrails_policy_validation.py b/app/runtime/tests/test_guardrails_policy_validation.py index b3bac83..c0ed8a3 100644 --- a/app/runtime/tests/test_guardrails_policy_validation.py +++ b/app/runtime/tests/test_guardrails_policy_validation.py @@ -9,7 +9,7 @@ import pytest -from app.runtime.state.guardrails_config import ( +from app.runtime.state.guardrails import ( GuardrailsConfigStore, PRESET_BALANCED, PRESET_PERMISSIVE, @@ -151,16 +151,19 @@ def setup_store(self, tmp_path) -> None: self.s = _store(tmp_path) self.s.apply_preset(PRESET_BALANCED, auto_models=False) - def test_file_ops_filtered_everywhere(self) -> None: - for ctx in ("interactive", "background"): - assert self.s.resolve_action("create", execution_context=ctx) == "filter" - assert self.s.resolve_action("edit", execution_context=ctx) == "filter" + def test_file_ops_filtered_interactive(self) -> None: + assert self.s.resolve_action("create", execution_context="interactive") == "filter" + assert self.s.resolve_action("edit", execution_context="interactive") == "filter" + + def test_file_ops_aitl_background(self) -> None: + assert self.s.resolve_action("create", execution_context="background") == "aitl" + assert self.s.resolve_action("edit", execution_context="background") == "aitl" def test_terminal_hitl_interactive(self) -> None: assert self.s.resolve_action("run", execution_context="interactive") == "hitl" - def test_terminal_denied_background(self) -> None: - assert self.s.resolve_action("run", execution_context="background") == "deny" + def test_terminal_aitl_background(self) -> None: + assert self.s.resolve_action("run", execution_context="background") == "aitl" def test_playwright_hitl_background(self) -> None: assert self.s.resolve_action( @@ -469,7 +472,7 @@ def setup_store(self, tmp_path) -> None: ) def test_voice_call_denied_by_rule(self) -> None: - # make_voice_call is in preset tool_policies (high risk -> hitl interactive / deny bg) + # make_voice_call is in preset tool_policies (high risk -> hitl interactive / aitl bg) # Since it's in tool_policies, the rule doesn't override it for preset contexts. # But the tool_policies entry comes first. # Interactive: make_voice_call = hitl (preset balanced: high risk interactive) @@ -477,10 +480,10 @@ def test_voice_call_denied_by_rule(self) -> None: "make_voice_call", execution_context="interactive", ) == "hitl" - def test_voice_call_denied_background(self) -> None: + def test_voice_call_aitl_background(self) -> None: assert self.s.resolve_action( "make_voice_call", execution_context="background", - ) == "deny" + ) == "aitl" def test_strong_model_create_files_filtered(self) -> None: assert self.s.resolve_action("create", model="gpt-5.3-codex") == "filter" diff --git a/app/runtime/tests/test_guardrails_presets.py b/app/runtime/tests/test_guardrails_presets.py index dc68f36..96e6b6e 100644 --- a/app/runtime/tests/test_guardrails_presets.py +++ b/app/runtime/tests/test_guardrails_presets.py @@ -2,7 +2,7 @@ from __future__ import annotations -from app.runtime.state.guardrails_config import ( +from app.runtime.state.guardrails import ( _ALL_PRESET_TOOL_IDS, _MODEL_TIERS, PRESET_BALANCED, @@ -136,10 +136,11 @@ def test_balanced_hitl_terminal_in_interactive(self) -> None: assert p["tool_policies"]["interactive"]["run"] == "hitl" assert p["tool_policies"]["interactive"]["bash"] == "hitl" - def test_balanced_denies_high_risk_in_background(self) -> None: + def test_balanced_aitl_terminal_voice_in_background(self) -> None: p = _build_preset_policies(PRESET_BALANCED) - assert p["tool_policies"]["background"]["run"] == "deny" - assert p["tool_policies"]["background"]["bash"] == "deny" + assert p["tool_policies"]["background"]["run"] == "aitl" + assert p["tool_policies"]["background"]["bash"] == "aitl" + assert p["tool_policies"]["background"]["make_voice_call"] == "aitl" assert p["tool_policies"]["background"]["mcp:github-mcp-server"] == "deny" assert p["tool_policies"]["background"]["mcp:azure-mcp-server"] == "deny" @@ -152,10 +153,10 @@ def test_balanced_filters_file_ops_in_interactive(self) -> None: assert p["tool_policies"]["interactive"]["create"] == "filter" assert p["tool_policies"]["interactive"]["edit"] == "filter" - def test_balanced_filters_file_ops_in_background(self) -> None: + def test_balanced_aitl_file_ops_in_background(self) -> None: p = _build_preset_policies(PRESET_BALANCED) - assert p["tool_policies"]["background"]["create"] == "filter" - assert p["tool_policies"]["background"]["edit"] == "filter" + assert p["tool_policies"]["background"]["create"] == "aitl" + assert p["tool_policies"]["background"]["edit"] == "aitl" # ── Permissive ── def test_permissive_filters_most_in_interactive(self) -> None: @@ -230,10 +231,10 @@ def test_apply_model_defaults_differentiates_tiers(self, tmp_path) -> None: assert strong["background"]["run"] == "hitl" assert strong["interactive"]["mcp:microsoft-learn"] == "filter" - # Standard (balanced): view filtered, run hitl interactive / deny bg + # Standard (balanced): view filtered, run hitl interactive / aitl bg (override) assert standard["interactive"]["view"] == "filter" assert standard["interactive"]["run"] == "hitl" - assert standard["background"]["run"] == "deny" + assert standard["background"]["run"] == "aitl" assert standard["interactive"]["mcp:microsoft-learn"] == "filter" # Cautious (restrictive): run hitl interactive / deny bg, github deny bg @@ -267,10 +268,10 @@ def test_cautious_model_mcp_risk_differentiation(self, tmp_path) -> None: # MS Learn (low risk) -> filter everywhere assert cautious["interactive"]["mcp:microsoft-learn"] == "filter" assert cautious["background"]["mcp:microsoft-learn"] == "filter" - # Playwright (medium risk) -> hitl interactive / deny background + # Playwright (medium risk) -> hitl interactive / deny background (own-tier restrictive) assert cautious["interactive"]["mcp:playwright"] == "hitl" assert cautious["background"]["mcp:playwright"] == "deny" - # GitHub/Azure (high risk) -> hitl interactive / deny background + # GitHub/Azure (high risk) -> hitl interactive / deny background (own-tier restrictive) assert cautious["interactive"]["mcp:github-mcp-server"] == "hitl" assert cautious["background"]["mcp:github-mcp-server"] == "deny" assert cautious["interactive"]["mcp:azure-mcp-server"] == "hitl" @@ -365,7 +366,7 @@ class TestBackgroundAgents: """Background agent metadata and resolve_action fallback.""" def test_list_background_agents(self) -> None: - from app.runtime.state.guardrails_config import list_background_agents + from app.runtime.state.guardrails import list_background_agents agents = list_background_agents() ids = [a["id"] for a in agents] diff --git a/app/runtime/tests/test_hitl.py b/app/runtime/tests/test_hitl.py index 34ce194..8b3762a 100644 --- a/app/runtime/tests/test_hitl.py +++ b/app/runtime/tests/test_hitl.py @@ -8,7 +8,7 @@ import pytest from app.runtime.agent.hitl import HitlInterceptor -from app.runtime.state.guardrails_config import GuardrailsConfigStore +from app.runtime.state.guardrails import GuardrailsConfigStore @pytest.fixture() @@ -22,7 +22,7 @@ def guardrails(tmp_path) -> GuardrailsConfigStore: store._path = tmp_path / "guardrails.json" store._policy_path = tmp_path / "policy.yaml" store._lock = __import__("threading").Lock() - from app.runtime.state.guardrails_config import GuardrailsConfig + from app.runtime.state.guardrails import GuardrailsConfig store._config = GuardrailsConfig(hitl_enabled=True, default_action="ask") store._rebuild_engine() @@ -45,8 +45,10 @@ class TestWebChatApproval: async def test_ask_chat_emits_approval_requested(self, hitl): events: list[tuple[str, dict]] = [] - hitl.set_emit(lambda t, d: events.append((t, d))) - hitl.set_execution_context("interactive") + hitl.bind_turn( + emit=lambda t, d: events.append((t, d)), + execution_context="interactive", + ) async def approve_later(): await asyncio.sleep(0.05) @@ -63,8 +65,7 @@ async def approve_later(): assert "approval_request" in event_types async def test_ask_chat_deny(self, hitl): - hitl.set_emit(lambda t, d: None) - hitl.set_execution_context("interactive") + hitl.bind_turn(emit=lambda t, d: None, execution_context="interactive") async def deny_later(): await asyncio.sleep(0.05) @@ -84,8 +85,7 @@ class TestBotChannelApproval: async def test_ask_bot_sends_confirmation_text(self, hitl): bot_reply = AsyncMock() - hitl.set_bot_reply_fn(bot_reply) - hitl.set_execution_context("background") + hitl.bind_turn(bot_reply_fn=bot_reply, execution_context="background") async def approve_later(): await asyncio.sleep(0.05) @@ -106,8 +106,7 @@ async def approve_later(): async def test_ask_bot_deny_with_no(self, hitl): bot_reply = AsyncMock() - hitl.set_bot_reply_fn(bot_reply) - hitl.set_execution_context("background") + hitl.bind_turn(bot_reply_fn=bot_reply, execution_context="background") async def deny_later(): await asyncio.sleep(0.05) @@ -123,8 +122,7 @@ async def deny_later(): async def test_ask_bot_deny_with_arbitrary_text(self, hitl): bot_reply = AsyncMock() - hitl.set_bot_reply_fn(bot_reply) - hitl.set_execution_context("background") + hitl.bind_turn(bot_reply_fn=bot_reply, execution_context="background") async def reply_later(): await asyncio.sleep(0.05) @@ -140,8 +138,7 @@ async def reply_later(): async def test_ask_bot_approve_yes_case_insensitive(self, hitl): bot_reply = AsyncMock() - hitl.set_bot_reply_fn(bot_reply) - hitl.set_execution_context("background") + hitl.bind_turn(bot_reply_fn=bot_reply, execution_context="background") async def approve_later(): await asyncio.sleep(0.05) @@ -195,7 +192,7 @@ async def test_deny_blocks(self, hitl, guardrails): guardrails._config.default_action = "deny" guardrails._rebuild_engine() events: list[tuple[str, dict]] = [] - hitl.set_emit(lambda t, d: events.append((t, d))) + hitl.bind_turn(emit=lambda t, d: events.append((t, d))) result = await hitl.on_pre_tool_use( {"toolCallId": "c2", "toolName": "run", "input": "rm -rf /"}, @@ -207,16 +204,16 @@ async def test_deny_blocks(self, hitl, guardrails): class TestClearCallbacks: - """Tests for clearing channel callbacks.""" + """Tests for bind_turn / unbind_turn lifecycle.""" def test_clear_emit(self, hitl): - hitl.set_emit(lambda t, d: None) - hitl.clear_emit() + hitl.bind_turn(emit=lambda t, d: None) + hitl.unbind_turn() assert hitl._emit is None def test_clear_bot_reply_fn(self, hitl): - hitl.set_bot_reply_fn(AsyncMock()) - hitl.clear_bot_reply_fn() + hitl.bind_turn(bot_reply_fn=AsyncMock()) + hitl.unbind_turn() assert hitl._bot_reply_fn is None @@ -225,10 +222,8 @@ class TestNoApprovalChannel: async def test_deny_when_no_channel_available(self, hitl): """HITL strategy with no approval channel must deny immediately.""" - # Ensure no callbacks are set - hitl.clear_emit() - hitl.clear_bot_reply_fn() - hitl.set_execution_context("background") + # Bind turn with no emit or bot_reply_fn + hitl.bind_turn(execution_context="background") result = await hitl.on_pre_tool_use( {"toolCallId": "no-ch-1", "toolName": "bash", "input": "date"}, @@ -241,9 +236,7 @@ async def test_deny_when_no_channel_available(self, hitl): async def test_deny_when_no_channel_does_not_block(self, hitl): """Ensure denial returns in <1s, not the 300s timeout.""" - hitl.clear_emit() - hitl.clear_bot_reply_fn() - hitl.set_execution_context("interactive") + hitl.bind_turn(execution_context="interactive") import time t0 = time.monotonic() @@ -258,7 +251,7 @@ async def test_deny_when_no_channel_does_not_block(self, hitl): async def test_ask_chat_denies_without_emitter(self, hitl): """_ask_chat must deny immediately if called with no emitter.""" - hitl.clear_emit() + hitl.unbind_turn() result = await hitl._ask_chat("orphan-1", "bash", "echo hello") assert result["permissionDecision"] == "deny" @@ -318,7 +311,7 @@ async def test_precheck_skipped_when_endpoint_not_configured(self, hitl, guardra shield.configured = False # No endpoint set shield.check = MagicMock() hitl.set_prompt_shield(shield) - hitl.set_execution_context("interactive") + hitl.bind_turn(execution_context="interactive") # AITL reviewer is not set, so it falls through to interactive # (which denies without an emitter). The point is that the @@ -343,7 +336,7 @@ async def test_precheck_runs_when_endpoint_configured(self, hitl, guardrails): shield_result.detail = "Attack found" shield.check = MagicMock(return_value=shield_result) hitl.set_prompt_shield(shield) - hitl.set_execution_context("interactive") + hitl.bind_turn(execution_context="interactive") result = await hitl.on_pre_tool_use( {"toolCallId": "f5", "toolName": "bash", "input": "ignore all"}, @@ -366,25 +359,22 @@ async def test_concurrent_messages_dont_lose_callback(self, hitl): bot_reply_1 = AsyncMock() bot_reply_2 = AsyncMock() - # Simulate Task0 setting its callback - hitl.set_bot_reply_fn(bot_reply_1) + # Simulate Task0 binding its turn + hitl.bind_turn(bot_reply_fn=bot_reply_1) assert hitl._bot_reply_fn is bot_reply_1 - # Task1 overwrites before Task0 clears (the race window) - hitl.set_bot_reply_fn(bot_reply_2) + # Task1 overwrites before Task0 unbinds (the race window) + hitl.bind_turn(bot_reply_fn=bot_reply_2) assert hitl._bot_reply_fn is bot_reply_2 - # Task0 clears -- this WAS the bug: it cleared Task1's callback - hitl.clear_bot_reply_fn() - # After the fix, this is protected by the lock in message_processor. - # The interceptor itself doesn't enforce ordering, but the processor does. + # Task0 unbinds -- protected by the lock in message_processor. + hitl.unbind_turn() assert hitl._bot_reply_fn is None async def test_bot_reply_set_before_tool_use(self, hitl): """bot_reply_fn must be set when on_pre_tool_use is called.""" bot_reply = AsyncMock() - hitl.set_bot_reply_fn(bot_reply) - hitl.set_execution_context("background") + hitl.bind_turn(bot_reply_fn=bot_reply, execution_context="background") async def approve_later(): await asyncio.sleep(0.05) diff --git a/app/runtime/tests/test_identity_routes.py b/app/runtime/tests/test_identity_routes.py index dc4c9f4..e003dbf 100644 --- a/app/runtime/tests/test_identity_routes.py +++ b/app/runtime/tests/test_identity_routes.py @@ -9,7 +9,7 @@ from aiohttp.test_utils import TestClient, TestServer from app.runtime.server.routes.identity_routes import IdentityRoutes -from app.runtime.state.guardrails_config import GuardrailsConfigStore +from app.runtime.state.guardrails import GuardrailsConfigStore def _build_app(routes: IdentityRoutes) -> web.Application: @@ -142,6 +142,108 @@ async def test_roles_with_assignments(self, mock_cfg) -> None: assert checks["Key Vault Secrets Officer"] is False assert checks["Azure ContainerApps Session Executor"] is False + # Session executor check should include detail about missing role + se_check = next( + c for c in data["checks"] + if c["role"] == "Azure ContainerApps Session Executor" + ) + assert se_check["detail"] == "Role not assigned to this identity" + + @pytest.mark.asyncio + @patch("app.runtime.server.routes.identity_routes.cfg") + async def test_roles_session_executor_wrong_scope(self, mock_cfg, tmp_path) -> None: + """Session Executor on wrong scope should report as not present.""" + mock_cfg.runtime_sp_app_id = "app-id" + mock_cfg.aca_mi_client_id = "" + mock_cfg.runtime_sp_tenant = "" + + az = MagicMock() + az.json.side_effect = [ + {"id": "obj-id-resolved"}, # _sp_show + [ + { + "roleDefinitionName": "Azure ContainerApps Session Executor", + "scope": "/subscriptions/sub1/resourceGroups/wrong-rg" + "/providers/Microsoft.App/sessionPools/wrong-pool", + "condition": "", + }, + ], + ] + + from app.runtime.state.sandbox_config import SandboxConfigStore + + sandbox_store = SandboxConfigStore(tmp_path / "sandbox.json") + sandbox_store.set_pool_metadata( + resource_group="polyclaw-sandbox-rg", + location="eastus", + pool_name="my-pool", + pool_id="/subscriptions/sub1/resourceGroups/polyclaw-sandbox-rg" + "/providers/Microsoft.App/sessionPools/my-pool", + endpoint="https://eastus.dynamicsessions.io", + ) + + routes = IdentityRoutes(az=az, sandbox_store=sandbox_store) + app = _build_app(routes) + async with TestClient(TestServer(app)) as client: + resp = await client.get("/api/identity/roles") + assert resp.status == 200 + data = await resp.json() + se_check = next( + c for c in data["checks"] + if c["role"] == "Azure ContainerApps Session Executor" + ) + assert se_check["present"] is False + assert "wrong scope" in se_check["detail"].lower() + assert "expected_scope" in se_check + + @pytest.mark.asyncio + @patch("app.runtime.server.routes.identity_routes.cfg") + async def test_roles_session_executor_correct_scope(self, mock_cfg, tmp_path) -> None: + """Session Executor on correct scope should report as present.""" + mock_cfg.runtime_sp_app_id = "app-id" + mock_cfg.aca_mi_client_id = "" + mock_cfg.runtime_sp_tenant = "" + + pool_scope = ( + "/subscriptions/sub1/resourceGroups/polyclaw-sandbox-rg" + "/providers/Microsoft.App/sessionPools/my-pool" + ) + + az = MagicMock() + az.json.side_effect = [ + {"id": "obj-id-resolved"}, # _sp_show + [ + { + "roleDefinitionName": "Azure ContainerApps Session Executor", + "scope": pool_scope, + "condition": "", + }, + ], + ] + + from app.runtime.state.sandbox_config import SandboxConfigStore + + sandbox_store = SandboxConfigStore(tmp_path / "sandbox.json") + sandbox_store.set_pool_metadata( + resource_group="polyclaw-sandbox-rg", + location="eastus", + pool_name="my-pool", + pool_id=pool_scope, + endpoint="https://eastus.dynamicsessions.io", + ) + + routes = IdentityRoutes(az=az, sandbox_store=sandbox_store) + app = _build_app(routes) + async with TestClient(TestServer(app)) as client: + resp = await client.get("/api/identity/roles") + assert resp.status == 200 + data = await resp.json() + se_check = next( + c for c in data["checks"] + if c["role"] == "Azure ContainerApps Session Executor" + ) + assert se_check["present"] is True + @pytest.mark.asyncio @patch("app.runtime.server.routes.identity_routes.cfg") async def test_roles_sp_show_fails_uses_app_id(self, mock_cfg) -> None: diff --git a/app/runtime/tests/test_incoming_media.py b/app/runtime/tests/test_incoming_media.py index ce70a8f..d8b888b 100644 --- a/app/runtime/tests/test_incoming_media.py +++ b/app/runtime/tests/test_incoming_media.py @@ -5,10 +5,8 @@ import re from pathlib import Path -from app.runtime.media.incoming import ( - _FILE_PATH_RE, - build_media_prompt, -) +from app.runtime.media.incoming import build_media_prompt +from app.runtime.media.outgoing import _FILE_PATH_RE class TestBuildMediaPrompt: diff --git a/app/runtime/tests/test_misconfig_checker.py b/app/runtime/tests/test_misconfig_checker.py index 49633a3..f9a9120 100644 --- a/app/runtime/tests/test_misconfig_checker.py +++ b/app/runtime/tests/test_misconfig_checker.py @@ -2,7 +2,7 @@ from __future__ import annotations -from app.runtime.services.misconfig_checker import CheckResult, Finding, MisconfigChecker +from app.runtime.services.security.misconfig_checker import CheckResult, Finding, MisconfigChecker class _FakeAzureCLI: diff --git a/app/runtime/tests/test_prerequisites.py b/app/runtime/tests/test_prerequisites.py index ed831c4..a80f3c7 100644 --- a/app/runtime/tests/test_prerequisites.py +++ b/app/runtime/tests/test_prerequisites.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock from app.runtime.config.settings import cfg -from app.runtime.server.setup_prerequisites import PrerequisitesRoutes +from app.runtime.server.setup.prerequisites import PrerequisitesRoutes from app.runtime.state.deploy_state import DeployStateStore, DeploymentRecord from app.runtime.state.infra_config import InfraConfigStore diff --git a/app/runtime/tests/test_prompt_shield.py b/app/runtime/tests/test_prompt_shield.py index b5f3eeb..7310ffc 100644 --- a/app/runtime/tests/test_prompt_shield.py +++ b/app/runtime/tests/test_prompt_shield.py @@ -8,7 +8,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch -from app.runtime.services.prompt_shield import ( +from app.runtime.services.security.prompt_shield import ( PromptShieldService, _BearerTokenProvider, ) diff --git a/app/runtime/tests/test_provisioner.py b/app/runtime/tests/test_provisioner.py index 59a3b11..68bbe5e 100644 --- a/app/runtime/tests/test_provisioner.py +++ b/app/runtime/tests/test_provisioner.py @@ -7,7 +7,7 @@ import pytest from app.runtime.config.settings import cfg -from app.runtime.services.provisioner import Provisioner +from app.runtime.services.deployment.provisioner import Provisioner from app.runtime.state.deploy_state import DeployStateStore from app.runtime.state.infra_config import InfraConfigStore from app.runtime.util.result import Result @@ -86,11 +86,7 @@ class TestProvision: """Full provision flow -- registers Entra app + runtime identity, no bot service.""" def test_skips_when_not_configured(self, provisioner, store): - store.bot = MagicMock() - store.bot.resource_group = "" - store.bot.location = "" - - # bot_configured returns False when rg/location are empty + # bot_configured returns False when rg/location are empty (default) with patch.object(type(store), "bot_configured", new_callable=lambda: property(lambda self: False)): steps = provisioner.provision() assert any(s["step"] == "bot_config" and s["status"] == "skip" for s in steps) diff --git a/app/runtime/tests/test_realtime_tools.py b/app/runtime/tests/test_realtime_tools.py index a18db4d..2343c17 100644 --- a/app/runtime/tests/test_realtime_tools.py +++ b/app/runtime/tests/test_realtime_tools.py @@ -128,7 +128,7 @@ async def test_not_found(self) -> None: class TestMakeRealtimeHook: """Verify that _make_realtime_hook creates a properly configured interceptor.""" - @patch("app.runtime.state.guardrails_config.get_guardrails_config") + @patch("app.runtime.state.guardrails.config.get_guardrails_config") def test_hook_sets_execution_context(self, mock_get_cfg: MagicMock) -> None: mock_store = MagicMock() mock_store.hitl_enabled = True @@ -140,7 +140,7 @@ def test_hook_sets_execution_context(self, mock_get_cfg: MagicMock) -> None: hook = _make_realtime_hook(agent) assert callable(hook) - @patch("app.runtime.state.guardrails_config.get_guardrails_config") + @patch("app.runtime.state.guardrails.config.get_guardrails_config") def test_hook_forwards_aitl_from_shared(self, mock_get_cfg: MagicMock) -> None: mock_store = MagicMock() mock_store.hitl_enabled = True @@ -157,7 +157,7 @@ def test_hook_forwards_aitl_from_shared(self, mock_get_cfg: MagicMock) -> None: hook = _make_realtime_hook(agent) assert callable(hook) - @patch("app.runtime.state.guardrails_config.get_guardrails_config") + @patch("app.runtime.state.guardrails.config.get_guardrails_config") def test_hook_works_without_shared_hitl(self, mock_get_cfg: MagicMock) -> None: mock_store = MagicMock() mock_store.hitl_enabled = True diff --git a/app/runtime/tests/test_sandbox_executor.py b/app/runtime/tests/test_sandbox_executor.py index f3ca1c2..c66f401 100644 --- a/app/runtime/tests/test_sandbox_executor.py +++ b/app/runtime/tests/test_sandbox_executor.py @@ -156,7 +156,7 @@ def test_create_data_zip_empty(self, tmp_path: Path) -> None: store = MagicMock() store.whitelist = ["nonexistent"] executor = SandboxExecutor(config_store=store) - with patch("app.runtime.sandbox.cfg") as mock_cfg: + with patch("app.runtime.sandbox.executor.cfg") as mock_cfg: mock_cfg.data_dir = tmp_path result = executor._create_data_zip() assert result is None @@ -166,7 +166,7 @@ def test_create_data_zip_with_file(self, tmp_path: Path) -> None: store = MagicMock() store.whitelist = ["test.json"] executor = SandboxExecutor(config_store=store) - with patch("app.runtime.sandbox.cfg") as mock_cfg: + with patch("app.runtime.sandbox.executor.cfg") as mock_cfg: mock_cfg.data_dir = tmp_path result = executor._create_data_zip() assert result is not None @@ -180,7 +180,7 @@ def test_create_data_zip_with_dir(self, tmp_path: Path) -> None: store = MagicMock() store.whitelist = ["subdir"] executor = SandboxExecutor(config_store=store) - with patch("app.runtime.sandbox.cfg") as mock_cfg: + with patch("app.runtime.sandbox.executor.cfg") as mock_cfg: mock_cfg.data_dir = tmp_path result = executor._create_data_zip() assert result is not None @@ -195,7 +195,7 @@ def test_merge_result_zip(self, tmp_path: Path) -> None: store = MagicMock() store.whitelist = ["allowed"] executor = SandboxExecutor(config_store=store) - with patch("app.runtime.sandbox.cfg") as mock_cfg: + with patch("app.runtime.sandbox.executor.cfg") as mock_cfg: mock_cfg.data_dir = tmp_path count = executor._merge_result_zip(buf.getvalue()) assert count == 1 @@ -210,7 +210,7 @@ def test_merge_result_zip_blocks_path_traversal(self, tmp_path: Path) -> None: store = MagicMock() store.whitelist = [] executor = SandboxExecutor(config_store=store) - with patch("app.runtime.sandbox.cfg") as mock_cfg: + with patch("app.runtime.sandbox.executor.cfg") as mock_cfg: mock_cfg.data_dir = tmp_path count = executor._merge_result_zip(buf.getvalue()) assert count == 0 @@ -344,7 +344,7 @@ class TestUploadBytesRetry: """Tests for _upload_bytes retry logic with exponential backoff.""" @pytest.mark.asyncio - @patch("app.runtime.sandbox._UPLOAD_BACKOFF_BASE", 0.0) + @patch("app.runtime.sandbox.executor._UPLOAD_BACKOFF_BASE", 0.0) async def test_upload_succeeds_on_first_attempt(self) -> None: store = MagicMock() executor = SandboxExecutor(config_store=store) @@ -359,11 +359,11 @@ async def test_upload_succeeds_on_first_attempt(self) -> None: result = await executor._upload_bytes( http, "https://endpoint", "sess-1", "file.zip", b"data", {}, ) - assert result is True + assert result == "" assert http.post.call_count == 1 @pytest.mark.asyncio - @patch("app.runtime.sandbox._UPLOAD_BACKOFF_BASE", 0.0) + @patch("app.runtime.sandbox.executor._UPLOAD_BACKOFF_BASE", 0.0) async def test_upload_retries_on_http_error_then_succeeds(self) -> None: store = MagicMock() executor = SandboxExecutor(config_store=store) @@ -385,11 +385,11 @@ async def test_upload_retries_on_http_error_then_succeeds(self) -> None: result = await executor._upload_bytes( http, "https://endpoint", "sess-1", "file.zip", b"data", {}, ) - assert result is True + assert result == "" assert http.post.call_count == 2 @pytest.mark.asyncio - @patch("app.runtime.sandbox._UPLOAD_BACKOFF_BASE", 0.0) + @patch("app.runtime.sandbox.executor._UPLOAD_BACKOFF_BASE", 0.0) async def test_upload_retries_on_exception_then_succeeds(self) -> None: store = MagicMock() executor = SandboxExecutor(config_store=store) @@ -407,11 +407,11 @@ async def test_upload_retries_on_exception_then_succeeds(self) -> None: result = await executor._upload_bytes( http, "https://endpoint", "sess-1", "data.zip", b"data", {}, ) - assert result is True + assert result == "" assert http.post.call_count == 2 @pytest.mark.asyncio - @patch("app.runtime.sandbox._UPLOAD_BACKOFF_BASE", 0.0) + @patch("app.runtime.sandbox.executor._UPLOAD_BACKOFF_BASE", 0.0) async def test_upload_fails_after_all_retries(self) -> None: store = MagicMock() executor = SandboxExecutor(config_store=store) @@ -428,5 +428,6 @@ async def test_upload_fails_after_all_retries(self) -> None: result = await executor._upload_bytes( http, "https://endpoint", "sess-1", "file.zip", b"data", {}, ) - assert result is False + assert result != "" + assert "503" in result assert http.post.call_count == 3 diff --git a/app/runtime/tests/test_spotlight.py b/app/runtime/tests/test_spotlight.py index 4584104..d519f71 100644 --- a/app/runtime/tests/test_spotlight.py +++ b/app/runtime/tests/test_spotlight.py @@ -6,7 +6,7 @@ from unittest.mock import patch from app.runtime.util.spotlight import datamark, delimit, spotlight -from app.runtime.state.guardrails_config import GuardrailsConfigStore +from app.runtime.state.guardrails import GuardrailsConfigStore import pytest @@ -95,13 +95,13 @@ class TestGuardrailsSpotlightingConfig: """Guardrails store persists and exposes the aitl_spotlighting toggle.""" def test_default_enabled(self, tmp_path: Path) -> None: - with patch("app.runtime.state.guardrails_config.cfg") as mock_cfg: + with patch("app.runtime.state.guardrails.config.cfg") as mock_cfg: mock_cfg.data_dir = tmp_path store = GuardrailsConfigStore(tmp_path / "guardrails.json") assert store.config.aitl_spotlighting is True def test_toggle_off_and_persist(self, tmp_path: Path) -> None: - with patch("app.runtime.state.guardrails_config.cfg") as mock_cfg: + with patch("app.runtime.state.guardrails.config.cfg") as mock_cfg: mock_cfg.data_dir = tmp_path store = GuardrailsConfigStore(tmp_path / "guardrails.json") store.set_aitl_spotlighting(False) @@ -112,7 +112,7 @@ def test_toggle_off_and_persist(self, tmp_path: Path) -> None: assert store2.config.aitl_spotlighting is False def test_toggle_on_and_persist(self, tmp_path: Path) -> None: - with patch("app.runtime.state.guardrails_config.cfg") as mock_cfg: + with patch("app.runtime.state.guardrails.config.cfg") as mock_cfg: mock_cfg.data_dir = tmp_path store = GuardrailsConfigStore(tmp_path / "guardrails.json") store.set_aitl_spotlighting(False) @@ -120,7 +120,7 @@ def test_toggle_on_and_persist(self, tmp_path: Path) -> None: assert store.config.aitl_spotlighting is True def test_to_dict_includes_spotlighting(self, tmp_path: Path) -> None: - with patch("app.runtime.state.guardrails_config.cfg") as mock_cfg: + with patch("app.runtime.state.guardrails.config.cfg") as mock_cfg: mock_cfg.data_dir = tmp_path store = GuardrailsConfigStore(tmp_path / "guardrails.json") d = store.to_dict() diff --git a/app/runtime/tests/test_tunnel_restriction.py b/app/runtime/tests/test_tunnel_restriction.py index 25486c8..b12ba12 100644 --- a/app/runtime/tests/test_tunnel_restriction.py +++ b/app/runtime/tests/test_tunnel_restriction.py @@ -18,7 +18,7 @@ from aiohttp.test_utils import TestClient, TestServer from app.runtime.config import cfg -from app.runtime.server.app import ( +from app.runtime.server.middleware import ( _CF_HEADERS, _PUBLIC_EXACT, _PUBLIC_PREFIXES, diff --git a/docs/content/architecture/state.md b/docs/content/architecture/state.md index f3d91fc..8fb528b 100644 --- a/docs/content/architecture/state.md +++ b/docs/content/architecture/state.md @@ -20,7 +20,7 @@ Each chat session is persisted as a separate JSON file containing message histor | One file per session | Easy inspection and backup | | Archival policies | `24h`, `7d`, `30d`, `never` | | Session resume | Last 20 messages loaded as context | -| Metadata | Model used, message count, timestamps | +| Metadata | Model, title, message count, created/updated timestamps | ### Memory Store @@ -35,7 +35,7 @@ Memory formation is triggered after `MEMORY_IDLE_MINUTES` (default: 5) of inacti ### Profile Store -**File**: `profile.json` +**File**: `agent_profile.json` Tracks the agent's identity and behavioral state: @@ -46,9 +46,15 @@ Tracks the agent's identity and behavioral state: | `location` | Timezone context | | `emotional_state` | Current mood (affects responses) | | `preferences` | Communication style preferences | -| `skill_usage` | Usage counts per skill | -| `interaction_log` | Recent interaction timestamps | -| `contribution_heatmap` | Activity by hour/day | + +Related data is stored in separate files: + +| File | Description | +|---|---| +| `skill_usage.json` | Usage counts per skill | +| `interactions.json` | Recent interaction log (last 1 000 entries) | + +The `get_full_profile()` helper merges the profile with skill usage, per-day contribution counts, and activity statistics. ### MCP Config @@ -73,9 +79,28 @@ Manages autonomous proactive messaging: |---|---| | `enabled` | Whether proactive messaging is active | | `pending` | Single pending message awaiting delivery | -| `sent` | Last 100 sent messages | +| `history` | Last 100 delivered messages with reactions | | `preferences` | Timing, frequency, and topic constraints | -| `daily_count` | Messages sent today | + +The `messages_sent_today()` and `hours_since_last_sent()` methods compute daily counts and gap tracking from the history rather than persisting them as separate fields. + +### Guardrails Config + +**Files**: `guardrails.json`, `policy.yaml` + +Stores human-in-the-loop (HITL) approval rules, tool-level and context-level policies, model-specific overrides, and Content Safety settings. A YAML policy file is generated alongside the JSON and consumed by the `PolicyEngine` at runtime. + +### Monitoring Config + +**File**: `monitoring.json` + +Stores OpenTelemetry and Application Insights configuration including connection strings, sampling ratio, live metrics toggle, and provisioning metadata. + +### Tool Activity Store + +**File**: `tool_activity.jsonl` + +Append-only JSON-lines log of every tool invocation. Each entry records tool name, arguments, result, duration, risk score, and Content Safety shield results. Supports query, timeline, CSV export, and session-level breakdowns for audit. ### Other State Files @@ -83,16 +108,16 @@ Manages autonomous proactive messaging: |---|---| | `SOUL.md` | Agent personality definition | | `scheduler.json` | Scheduled task definitions | -| `deploy_state.json` | Deployment records | -| `infra_config.json` | Infrastructure configuration | -| `plugin_config.json` | Plugin enabled/disabled state | -| `sandbox_config.json` | Sandbox configuration | -| `foundry_iq_config.json` | Azure AI Foundry IQ settings | -| `conversation_references.json` | Bot Framework conversation references | +| `deployments.json` | Deployment records | +| `infra.json` | Infrastructure configuration (bot, channels, voice) | +| `plugins.json` | Plugin enabled/disabled state | +| `sandbox.json` | Sandbox configuration and session pool metadata | +| `foundry_iq.json` | Azure AI Foundry IQ / Search settings | +| `conversation_refs.json` | Bot Framework conversation references | ## Design Principles - **No database required** -- everything is flat files for simplicity and portability -- **Human-readable** -- JSON and Markdown files can be inspected and edited manually +- **Human-readable** -- JSON, JSONL, and Markdown files can be inspected and edited manually - **Docker-friendly** -- mount `~/.polyclaw` as a volume for persistence -- **Atomic writes** -- state modules use write-then-rename for crash safety +- **Thread-safe I/O** -- shared stores use `threading.Lock` for concurrent access diff --git a/docs/content/configuration/_index.md b/docs/content/configuration/_index.md index 938b4fc..8c0dc62 100644 --- a/docs/content/configuration/_index.md +++ b/docs/content/configuration/_index.md @@ -10,12 +10,13 @@ Polyclaw is configured through environment variables loaded from a `.env` file o | Variable | Default | Description | |---|---|---| | `GITHUB_TOKEN` | -- | GitHub PAT with Copilot access. Supports `@kv:` prefix. | -| `COPILOT_MODEL` | `claude-sonnet-4-20250514` | Default LLM model for conversations | +| `COPILOT_MODEL` | `claude-sonnet-4.6` | Default LLM model for conversations | | `COPILOT_AGENT` | -- | Optional Copilot agent name | -| `ADMIN_PORT` | `8000` | Admin server listen port | +| `ADMIN_PORT` | `9090` | Admin server listen port | | `ADMIN_SECRET` | -- | Bearer token for API authentication. Supports `@kv:` prefix. | | `POLYCLAW_DATA_DIR` | `~/.polyclaw` | Root directory for all persistent data | | `DOTENV_PATH` | -- | Custom path to `.env` file | +| `POLYCLAW_SERVER_MODE` | `combined` | Server mode: `combined`, `admin`, or `runtime` | ## Bot Framework @@ -42,7 +43,7 @@ Polyclaw is configured through environment variables loaded from a `.env` file o | Variable | Default | Description | |---|---|---| -| `MEMORY_MODEL` | `claude-sonnet-4-20250514` | Model used for memory consolidation | +| `MEMORY_MODEL` | `claude-sonnet-4.6` | Model used for memory consolidation | | `MEMORY_IDLE_MINUTES` | `5` | Minutes of inactivity before memory formation triggers | ## Proactive Messaging diff --git a/docs/content/features/monitoring.md b/docs/content/features/monitoring.md index 0d9f4bc..4be0079 100644 --- a/docs/content/features/monitoring.md +++ b/docs/content/features/monitoring.md @@ -84,11 +84,24 @@ When enabled, the Live Metrics stream provides a real-time view of request rate, --- +## Agent Framework Dashboard in Application Insights + +Polyclaw emits telemetry in the format expected by the Microsoft Agent Framework. This means the built-in **Agent Framework dashboard** available in Application Insights works out of the box -- no extra configuration or custom queries required. + +The dashboard surfaces agent-level metrics such as session counts, tool invocation success rates, model call latency, and failure breakdowns, all derived from the spans and semantic attributes that Polyclaw already produces. + +![Agent Framework dashboard in Application Insights](/screenshots/mafpreview-appinsights.png) + +Because the telemetry follows the Agent Framework conventions (`gen_ai.system`, `gen_ai.request.model`, operation names, dependency types), the dashboard can correlate traces across the full request lifecycle -- from incoming user messages through model calls and tool executions to the final response. + +--- + ## Dashboard Links After provisioning, the monitoring configuration provides direct links to: - **Azure Portal** -- Application Insights overview, transaction search, failures, and performance views +- **Agent Framework Dashboard** -- the built-in agent monitoring view in Application Insights, powered by the telemetry format described above - **Grafana Agent Dashboard** -- pre-built dashboard URL for Azure Managed Grafana (if configured) --- diff --git a/docs/themes/polyclaw/static/screenshots/mafpreview-appinsights.png b/docs/themes/polyclaw/static/screenshots/mafpreview-appinsights.png new file mode 100644 index 0000000000000000000000000000000000000000..5aa43d5085b6b41630559effc69bf097eecf3d3b GIT binary patch literal 731276 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

u6qj#eTh}IJdz?)kPc!h8|oC>VfRr#h*%nb;~aF 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�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

V&VA%cQsy0zxz&3M)t&E4p8U~>LB+- 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�O|-Jn@}NTQH~ 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 aVOH{#T79IZpSbHH_&C@(( Date: Mon, 23 Feb 2026 16:42:17 +0100 Subject: [PATCH 2/2] feat: update model labels in layered-security.svg for clarity --- app/frontend/public/layered-security.svg | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/app/frontend/public/layered-security.svg b/app/frontend/public/layered-security.svg index 1c8b2eb..a92ac12 100644 --- a/app/frontend/public/layered-security.svg +++ b/app/frontend/public/layered-security.svg @@ -40,26 +40,17 @@ - claude-opus-4.6 - Tier 3 • Safe + Reasoning Models + Tier 3 • Safer - gpt-4.1 + Smaller Models Tier 2 • Standard - gpt-5-mini + Legacy Models Tier 1 • Cautious - - - Strongest - - - Outermost - - - - Defense in Depth + Defense in Depth

!viatgBm`a1e8o8xM}g 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%0DiksT?%kF3VG;upc*&+ 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