diff --git a/AGENTS.md b/AGENTS.md index b50034b..79b9d25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,8 @@ Reference specific personas when requesting work: ## Learned User Preferences - When preparing a merge to `main` or a release, keep `docs/CHANGELOG.md` **Unreleased** accurate; on request, align listed dependency or tooling changes with the delta since the previous git tag (including `pyproject.toml`). +- Prefer `docs/CHANGELOG.md` `Unreleased` entries grouped into `Added` / `Changed` / `Fixed` (instead of custom feature headings). +- Dependabot PRs should target `develop`, not `main` (set `target-branch: "develop"` in `.github/dependabot.yml`). ## Learned Workspace Facts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b670e27..41e6c5f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -15,10 +15,26 @@ All notable changes to this project will be documented in this file. ### Changed - **FastAPI lifespan cleanup** — Controller shutdown now cancels the scheduled-step background loop and waits briefly for it to stop. +- **Supervaizer v2 agent methods** — SDK agents can now declare optional standard actions such as `agent.refresh` plus custom agent actions through the same `AgentMethods` structure used for job methods, and the A2A runtime registers those handlers automatically. ### Tests - `tests/test_server.py` — scheduler task cancellation and bounded shutdown waiting during FastAPI lifespan shutdown. +- `tests/test_a2a.py` — standard and custom agent method dispatch through the v2 A2A controller. +- `tests/test_agent.py` — agent-level v2 method registration and contract validation. +- `tests/test_contracts.py` — typed agent method contract serialization. + +### Tests + +- `tests/test_common.py` — structured JSON log output for API access-denial records +- `just test` + +| Status | Count | +| ---------- | ----- | +| ✅ Passed | 663 | +| 🤔 Skipped | 0 | +| 🔴 Failed | 0 | +| ⏱️ in | 136s | ## [1.1.1] - 2026-05-20 diff --git a/src/supervaizer/scheduled_steps.py b/src/supervaizer/scheduled_steps.py new file mode 100644 index 0000000..24bcbcb --- /dev/null +++ b/src/supervaizer/scheduled_steps.py @@ -0,0 +1,53 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any + +from supervaizer.common import log + +if TYPE_CHECKING: + from supervaizer.server import Server + +SCHEDULED_STEP_POLL_SECONDS = 60 + + +def _execute_scheduled_method(method_path: str, params: dict[str, Any]) -> Any: + """Execute a method by its full dotted path.""" + module_name, func_name = method_path.rsplit(".", 1) + module = __import__(module_name, fromlist=[func_name]) + method = getattr(module, func_name) + return method(**params) + + +async def _run_scheduled_step_loop(server: Server) -> None: + """Poll for due scheduled steps and execute them.""" + from supervaizer.case import Cases + + while True: + await asyncio.sleep(SCHEDULED_STEP_POLL_SECONDS) + try: + cases = Cases() + due_steps = cases.get_due_scheduled_steps() + for _case, _step_index, update in due_steps: + if not update.scheduled_method: + continue + try: + object.__setattr__(update, "scheduled_status", "executing") + log.info(f"[Scheduled step] Executing: {update.name}") + _execute_scheduled_method( + update.scheduled_method, + update.scheduled_params or {}, + ) + object.__setattr__(update, "scheduled_status", "completed") + log.info(f"[Scheduled step] Completed: {update.name}") + except Exception as exc: + object.__setattr__(update, "scheduled_status", "failed") + log.error(f"[Scheduled step] Failed: {update.name}: {exc}") + except Exception as exc: + log.error(f"[Scheduled step loop] Error: {exc}") diff --git a/src/supervaizer/server.py b/src/supervaizer/server.py index a6f3fea..1ff6137 100644 --- a/src/supervaizer/server.py +++ b/src/supervaizer/server.py @@ -17,14 +17,10 @@ import uuid from collections.abc import AsyncIterator, Callable from contextlib import asynccontextmanager, suppress -from datetime import datetime # <-- REMOVED: Path (no longer needed) -from hashlib import sha256 from typing import Any, ClassVar, TypeVar, cast from urllib.parse import urlunparse -from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey from fastapi import FastAPI, HTTPException, Request, Security, status from fastapi.exceptions import RequestValidationError @@ -32,7 +28,7 @@ from fastapi.security import APIKeyHeader # <-- REMOVED: Jinja2Templates (home page moved to routers/public.py) -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import ConfigDict, Field, field_validator from rich import inspect from supervaizer.__version__ import VERSION @@ -53,7 +49,6 @@ from supervaizer.contracts import ( API_VERSION, V2WorkspaceAuthorizationSettings, - controller_contract_info, ) from supervaizer.instructions import display_instructions from supervaizer.protocol.a2a.controller import ( @@ -68,7 +63,31 @@ create_public_router, ) # <-- ADDED from supervaizer.routes import get_server # <-- MODIFIED: removed per-router imports -from supervaizer.storage import StorageManager, load_running_entities_on_startup +from supervaizer.scheduled_steps import ( + _execute_scheduled_method as _execute_scheduled_method, + _run_scheduled_step_loop, +) +from supervaizer.server_config import ( + _controller_key_fingerprint, + _env_bool as _env_bool, + _get_or_create_private_key, + _get_or_create_server_id, + _resolve_workspace_authorization_settings, +) +from supervaizer.server_info import ( + ServerInfo as ServerInfo, + get_server_info_from_live as get_server_info_from_live, + get_server_info_from_storage as get_server_info_from_storage, + save_server_info_to_storage, +) +from supervaizer.server_registration import build_server_registration_info +from supervaizer.storage import load_running_entities_on_startup +from supervaizer.studio_handshake import ( + apply_workspace_authorization_agent_bindings, + apply_workspace_authorization_handshake, + validate_registration_handshake, + validate_studio_a2a_workspace_authorization, +) from supervaizer.workspace_authorization import ( validate_workspace_authorization_settings, ) @@ -78,206 +97,6 @@ T = TypeVar("T") SCHEDULED_STEP_SHUTDOWN_TIMEOUT_SECONDS = 5.0 -# Additional imports for server persistence - - -def _get_or_create_server_id() -> str: - """Use SUPERVAIZER_SERVER_ID from env if set; else create uuid and set env.""" - existing = os.getenv("SUPERVAIZER_SERVER_ID") - if existing and len(existing) > 5: - return existing - new_id = str(uuid.uuid4()) - os.environ["SUPERVAIZER_SERVER_ID"] = new_id - return new_id - - -def _controller_key_fingerprint(api_key: str | None) -> str | None: - if not api_key: - return None - return sha256(api_key.encode("utf-8")).hexdigest()[:12] - - -def _resolve_workspace_authorization_settings( - explicit_settings: V2WorkspaceAuthorizationSettings | dict[str, Any] | None, -) -> V2WorkspaceAuthorizationSettings: - if explicit_settings is not None: - return V2WorkspaceAuthorizationSettings.model_validate(explicit_settings) - return V2WorkspaceAuthorizationSettings( - enabled=_env_bool("SUPERVAIZER_WORKSPACE_AUTH_REQUIRED", default=False), - issuer=os.getenv("SUPERVAIZER_WORKSPACE_AUTH_ISSUER") or None, - audience=os.getenv("SUPERVAIZER_WORKSPACE_AUTH_AUDIENCE") or None, - public_key_pem=os.getenv("SUPERVAIZER_WORKSPACE_AUTH_PUBLIC_KEY") or None, - jwks_url=os.getenv("SUPERVAIZER_WORKSPACE_AUTH_JWKS_URL") or None, - leeway_seconds=int( - os.getenv("SUPERVAIZER_WORKSPACE_AUTH_LEEWAY_SECONDS", "30") - ), - ) - - -def _env_bool(name: str, *, default: bool) -> bool: - raw_value = os.getenv(name) - if raw_value is None: - return default - return raw_value.strip().lower() in {"1", "true", "yes", "on"} - - -def _get_or_create_private_key() -> RSAPrivateKey: - """Use SUPERVAIZER_PRIVATE_KEY from env if set; else create key and set env.""" - pem = os.getenv("SUPERVAIZER_PRIVATE_KEY") - if pem and len(pem) > 5: - try: - key = serialization.load_pem_private_key( - pem.encode("utf-8"), - password=None, - backend=default_backend(), - ) - return cast(RSAPrivateKey, key) - except Exception as e: - log.warning( - f"[Server] Invalid SUPERVAIZER_PRIVATE_KEY, generating new key: {e}" - ) - private_key = rsa.generate_private_key( - public_exponent=65537, - key_size=2048, - backend=default_backend(), - ) - pem_bytes = private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ) - os.environ["SUPERVAIZER_PRIVATE_KEY"] = pem_bytes.decode("utf-8") - log.info("[Server] Generated new RSA private key and set SUPERVAIZER_PRIVATE_KEY") - return private_key - - -class ServerInfo(BaseModel): - """Complete server information for storage.""" - - id: str = "server_instance" # Fixed ID for singleton - host: str - port: int - api_version: str - environment: str - agents: list[dict[str, str]] - start_time: float - created_at: str - updated_at: str - - -def save_server_info_to_storage(server_instance: "Server") -> None: - """Save server information to storage.""" - try: - storage = StorageManager() - - # Get agent information - agents = [] - if hasattr(server_instance, "agents") and server_instance.agents: - for agent in server_instance.agents: - agents.append({ - "name": agent.name, - "description": agent.description, - "version": agent.version, - "api_path": agent.path, - "slug": agent.slug, - "instructions_path": agent.instructions_path, - }) - - # Create server info - server_info = ServerInfo( - id="server_instance", - host=getattr(server_instance, "host", "N/A"), - port=getattr(server_instance, "port", 0), - api_version=API_VERSION, - environment=os.getenv("SUPERVAIZER_ENVIRONMENT", "development"), - agents=agents, - start_time=time.time(), - created_at=datetime.now().isoformat(), - updated_at=datetime.now().isoformat(), - ) - - # Save to storage under the fixed singleton id so retrieval works - storage.save_object("ServerInfo", server_info.model_dump()) - - log.info( - f"[Server] Server info saved to storage: {server_info.host}:{server_info.port}" - ) - - except Exception as e: - log.error(f"[Server] Failed to save server info to storage: {e}") - - -def get_server_info_from_storage() -> ServerInfo | None: - """Get server information from storage.""" - storage = StorageManager() - server_data = storage.get_object_by_id("ServerInfo", "server_instance") - - if server_data: - return ServerInfo.model_validate(server_data) - return None - - -def get_server_info_from_live(server_instance: "Server") -> ServerInfo: - """Build ServerInfo from a live Server instance (for when storage has no ServerInfo, e.g. no persistence).""" - agents = [] - if hasattr(server_instance, "agents") and server_instance.agents: - for agent in server_instance.agents: - agents.append({ - "name": agent.name, - "description": agent.description, - "version": agent.version, - "api_path": agent.path, - "slug": agent.slug, - "instructions_path": agent.instructions_path, - }) - start_time = getattr(server_instance, "_start_time", time.time()) - return ServerInfo( - host=getattr(server_instance, "host", "N/A"), - port=getattr(server_instance, "port", 0), - api_version=API_VERSION, - environment=os.getenv("SUPERVAIZER_ENVIRONMENT", "development"), - agents=agents, - start_time=start_time, - created_at=datetime.now().isoformat(), - updated_at=datetime.now().isoformat(), - ) - - -def _execute_scheduled_method(method_path: str, params: dict) -> Any: - """Execute a method by its full dotted path (module.func).""" - module_name, func_name = method_path.rsplit(".", 1) - module = __import__(module_name, fromlist=[func_name]) - method = getattr(module, func_name) - return method(**params) - - -async def _run_scheduled_step_loop(server: "Server") -> None: - """Poll for due scheduled steps every 60 seconds and execute them.""" - from supervaizer.case import Cases - - while True: - await asyncio.sleep(60) - try: - cases = Cases() - due_steps = cases.get_due_scheduled_steps() - for _case, _step_index, update in due_steps: - if not update.scheduled_method: - continue - try: - object.__setattr__(update, "scheduled_status", "executing") - log.info(f"[Scheduled step] Executing: {update.name}") - _execute_scheduled_method( - update.scheduled_method, - update.scheduled_params or {}, - ) - object.__setattr__(update, "scheduled_status", "completed") - log.info(f"[Scheduled step] Completed: {update.name}") - except Exception as exc: - object.__setattr__(update, "scheduled_status", "failed") - log.error(f"[Scheduled step] Failed: {update.name}: {exc}") - except Exception as exc: - log.error(f"[Scheduled step loop] Error: {exc}") - class ServerAbstract(SvBaseModel): """ @@ -729,30 +548,7 @@ def uri(self) -> str: @property def registration_info(self) -> dict[str, Any]: """Get registration info for the server.""" - assert self.public_key is not None, "Public key not initialized" - contract = controller_contract_info() - return { - "server_id": self.server_id, - "url": self.public_url, - "uri": self.uri, - "api_version": API_VERSION, - "controller_version": VERSION, - **contract, - "environment": self.environment, - "public_key": str( - self.public_key.public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo, - ).decode("utf-8") - ), - "api_key": self.api_key, - "docs": { - "swagger": f"{self.public_url}{self.app.docs_url}", - "redoc": f"{self.public_url}{self.app.redoc_url}", - "openapi": f"{self.public_url}{self.app.openapi_url}", - }, - "agents": [agent.registration_info for agent in self.agents], - } + return build_server_registration_info(self) def launch(self, log_level: str | None = "INFO") -> None: if log_level: @@ -827,132 +623,20 @@ def instructions(self) -> None: ) def _validate_registration_handshake(self, result: ApiSuccess) -> None: - detail = result.detail if isinstance(result.detail, dict) else {} - response_object = detail.get("object") - if not isinstance(response_object, dict): - raise RuntimeError( - "Studio registration handshake failed: server.register response did not " - "include a response object. Studio-to-agent API key persistence could not " - "be verified." - ) - handshake = response_object.get("supervaizer_handshake") - if not isinstance(handshake, dict): - response_keys = sorted(str(key) for key in response_object.keys()) - raise RuntimeError( - "Studio registration handshake failed: server.register response did not " - "include supervaizer_handshake. Studio-to-agent API key persistence could " - "not be verified. Check that SUPERVAIZE_API_URL points to a Studio " - "instance that supports the Supervaizer v2 registration handshake. " - f"response_keys={response_keys}" - ) - if handshake.get("controller_api_key_match") is True: - self._apply_workspace_authorization_handshake(handshake) - log.info( - "[Server launch] Studio registration handshake verified " - f"server_id={handshake.get('server_id')} " - f"controller_key_fingerprint={_controller_key_fingerprint(self.api_key)}" - ) - return - raise RuntimeError( - "Studio registration handshake failed: Studio did not persist the controller API key " - f"for server_id={handshake.get('server_id')}. " - f"controller_key_fingerprint={_controller_key_fingerprint(self.api_key)} " - f"studio_fingerprint={handshake.get('stored_controller_api_key_fingerprint')} " - f"reason={handshake.get('reason')}" - ) + validate_registration_handshake(self, result) def _validate_studio_a2a_workspace_authorization(self) -> None: - if not self.a2a_endpoints or self.supervisor_account is None: - return - if self.workspace_authorization.enabled: - return - raise RuntimeError( - "Studio-registered Supervaizer v2 A2A requires workspace authorization. " - "Set SUPERVAIZER_WORKSPACE_AUTH_REQUIRED=true and configure " - "SUPERVAIZER_WORKSPACE_AUTH_ISSUER plus either " - "SUPERVAIZER_WORKSPACE_AUTH_PUBLIC_KEY or SUPERVAIZER_WORKSPACE_AUTH_JWKS_URL." - ) + validate_studio_a2a_workspace_authorization(self) def _apply_workspace_authorization_handshake( self, handshake: dict[str, Any] ) -> None: - if not self.workspace_authorization.enabled: - return - - workspace_authorization = handshake.get("workspace_authorization") - if not isinstance(workspace_authorization, dict): - raise RuntimeError( - "Studio registration handshake failed: workspace authorization is enabled " - "but supervaizer_handshake.workspace_authorization is missing." - ) - - audience = workspace_authorization.get("audience") - if not isinstance(audience, str) or not audience.strip(): - raise RuntimeError( - "Studio registration handshake failed: workspace authorization is enabled " - "but supervaizer_handshake.workspace_authorization.audience is missing." - ) - - configured_audience = self.workspace_authorization.audience - if configured_audience and configured_audience != audience: - raise RuntimeError( - "Studio registration handshake failed: configured workspace authorization " - "audience does not match Studio's server audience." - ) - - self.workspace_authorization = self.workspace_authorization.model_copy( - update={"audience": audience} - ) - agent_bindings = workspace_authorization.get("agents") - if not isinstance(agent_bindings, list): - raise RuntimeError( - "Studio registration handshake failed: workspace authorization is enabled " - "but supervaizer_handshake.workspace_authorization.agents is missing." - ) - self._apply_workspace_authorization_agent_bindings(agent_bindings) + apply_workspace_authorization_handshake(self, handshake) def _apply_workspace_authorization_agent_bindings( self, agent_bindings: list[Any] ) -> None: - bindings_by_slug: dict[str, str] = {} - for binding in agent_bindings: - if not isinstance(binding, dict): - raise RuntimeError( - "Studio registration handshake failed: workspace authorization agent " - "binding must be an object." - ) - agent_id = binding.get("id") - agent_slug = binding.get("slug") - if not isinstance(agent_id, str) or not agent_id.strip(): - raise RuntimeError( - "Studio registration handshake failed: workspace authorization agent " - "binding is missing id." - ) - if not isinstance(agent_slug, str) or not agent_slug.strip(): - raise RuntimeError( - "Studio registration handshake failed: workspace authorization agent " - "binding is missing slug." - ) - bindings_by_slug[agent_slug] = agent_id - - missing_agents = [] - for agent in self.agents: - studio_agent_id = bindings_by_slug.get(agent.slug) - if not studio_agent_id: - missing_agents.append(agent.slug) - continue - if agent.server_agent_id and agent.server_agent_id != studio_agent_id: - raise RuntimeError( - "Studio registration handshake failed: workspace authorization agent " - f"id mismatch for slug={agent.slug}." - ) - agent.server_agent_id = studio_agent_id - - if missing_agents: - raise RuntimeError( - "Studio registration handshake failed: workspace authorization did not " - f"return Studio agent id(s) for slug(s): {', '.join(missing_agents)}" - ) + apply_workspace_authorization_agent_bindings(self, agent_bindings) def decrypt(self, encrypted_parameters: str) -> str: """Decrypt parameters using the server's private key.""" diff --git a/src/supervaizer/server_config.py b/src/supervaizer/server_config.py new file mode 100644 index 0000000..d17df9f --- /dev/null +++ b/src/supervaizer/server_config.py @@ -0,0 +1,90 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +from __future__ import annotations + +import os +import uuid +from hashlib import sha256 +from typing import Any, cast + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey + +from supervaizer.common import log +from supervaizer.contracts import V2WorkspaceAuthorizationSettings + + +def _get_or_create_server_id() -> str: + """Use SUPERVAIZER_SERVER_ID from env if set; else create uuid and set env.""" + existing = os.getenv("SUPERVAIZER_SERVER_ID") + if existing and len(existing) > 5: + return existing + new_id = str(uuid.uuid4()) + os.environ["SUPERVAIZER_SERVER_ID"] = new_id + return new_id + + +def _controller_key_fingerprint(api_key: str | None) -> str | None: + if not api_key: + return None + return sha256(api_key.encode("utf-8")).hexdigest()[:12] + + +def _resolve_workspace_authorization_settings( + explicit_settings: V2WorkspaceAuthorizationSettings | dict[str, Any] | None, +) -> V2WorkspaceAuthorizationSettings: + if explicit_settings is not None: + return V2WorkspaceAuthorizationSettings.model_validate(explicit_settings) + return V2WorkspaceAuthorizationSettings( + enabled=_env_bool("SUPERVAIZER_WORKSPACE_AUTH_REQUIRED", default=False), + issuer=os.getenv("SUPERVAIZER_WORKSPACE_AUTH_ISSUER") or None, + audience=os.getenv("SUPERVAIZER_WORKSPACE_AUTH_AUDIENCE") or None, + public_key_pem=os.getenv("SUPERVAIZER_WORKSPACE_AUTH_PUBLIC_KEY") or None, + jwks_url=os.getenv("SUPERVAIZER_WORKSPACE_AUTH_JWKS_URL") or None, + leeway_seconds=int( + os.getenv("SUPERVAIZER_WORKSPACE_AUTH_LEEWAY_SECONDS", "30") + ), + ) + + +def _env_bool(name: str, *, default: bool) -> bool: + raw_value = os.getenv(name) + if raw_value is None: + return default + return raw_value.strip().lower() in {"1", "true", "yes", "on"} + + +def _get_or_create_private_key() -> RSAPrivateKey: + """Use SUPERVAIZER_PRIVATE_KEY from env if set; else create key and set env.""" + pem = os.getenv("SUPERVAIZER_PRIVATE_KEY") + if pem and len(pem) > 5: + try: + key = serialization.load_pem_private_key( + pem.encode("utf-8"), + password=None, + backend=default_backend(), + ) + return cast(RSAPrivateKey, key) + except Exception as e: + log.warning( + f"[Server] Invalid SUPERVAIZER_PRIVATE_KEY, generating new key: {e}" + ) + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=default_backend(), + ) + pem_bytes = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + os.environ["SUPERVAIZER_PRIVATE_KEY"] = pem_bytes.decode("utf-8") + log.info("[Server] Generated new RSA private key and set SUPERVAIZER_PRIVATE_KEY") + return private_key diff --git a/src/supervaizer/server_info.py b/src/supervaizer/server_info.py new file mode 100644 index 0000000..03aac9f --- /dev/null +++ b/src/supervaizer/server_info.py @@ -0,0 +1,100 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +from __future__ import annotations + +import os +import time +from datetime import datetime +from typing import Any + +from pydantic import BaseModel + +from supervaizer.common import log +from supervaizer.contracts import API_VERSION +from supervaizer.storage import StorageManager + +SERVER_INFO_ID = "server_instance" +SERVER_INFO_KIND = "ServerInfo" + + +class ServerInfo(BaseModel): + """Complete server information for storage.""" + + id: str = SERVER_INFO_ID + host: str + port: int + api_version: str + environment: str + agents: list[dict[str, str]] + start_time: float + created_at: str + updated_at: str + + +def save_server_info_to_storage(server_instance: Any) -> None: + """Save server information to storage.""" + try: + storage = StorageManager() + server_info = _build_server_info( + server_instance, + start_time=time.time(), + ) + storage.save_object(SERVER_INFO_KIND, server_info.model_dump()) + log.info( + f"[Server] Server info saved to storage: {server_info.host}:{server_info.port}" + ) + except Exception as e: + log.error(f"[Server] Failed to save server info to storage: {e}") + + +def get_server_info_from_storage() -> ServerInfo | None: + """Get server information from storage.""" + storage = StorageManager() + server_data = storage.get_object_by_id(SERVER_INFO_KIND, SERVER_INFO_ID) + if server_data: + return ServerInfo.model_validate(server_data) + return None + + +def get_server_info_from_live(server_instance: Any) -> ServerInfo: + """Build server information from a live server instance.""" + return _build_server_info( + server_instance, + start_time=getattr(server_instance, "_start_time", time.time()), + ) + + +def _build_server_info(server_instance: Any, *, start_time: float) -> ServerInfo: + timestamp = datetime.now().isoformat() + return ServerInfo( + id=SERVER_INFO_ID, + host=getattr(server_instance, "host", "N/A"), + port=getattr(server_instance, "port", 0), + api_version=API_VERSION, + environment=os.getenv("SUPERVAIZER_ENVIRONMENT", "development"), + agents=_build_agent_info(server_instance), + start_time=start_time, + created_at=timestamp, + updated_at=timestamp, + ) + + +def _build_agent_info(server_instance: Any) -> list[dict[str, str]]: + agents = getattr(server_instance, "agents", None) + if not agents: + return [] + return [ + { + "name": agent.name, + "description": agent.description, + "version": agent.version, + "api_path": agent.path, + "slug": agent.slug, + "instructions_path": agent.instructions_path, + } + for agent in agents + ] diff --git a/src/supervaizer/server_registration.py b/src/supervaizer/server_registration.py new file mode 100644 index 0000000..8ea4d20 --- /dev/null +++ b/src/supervaizer/server_registration.py @@ -0,0 +1,42 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +from __future__ import annotations + +from typing import Any + +from cryptography.hazmat.primitives import serialization + +from supervaizer.__version__ import VERSION +from supervaizer.contracts import API_VERSION, controller_contract_info + + +def build_server_registration_info(server: Any) -> dict[str, Any]: + """Build the Studio-compatible server.register payload details.""" + assert server.public_key is not None, "Public key not initialized" + contract = controller_contract_info() + return { + "server_id": server.server_id, + "url": server.public_url, + "uri": server.uri, + "api_version": API_VERSION, + "controller_version": VERSION, + **contract, + "environment": server.environment, + "public_key": str( + server.public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("utf-8") + ), + "api_key": server.api_key, + "docs": { + "swagger": f"{server.public_url}{server.app.docs_url}", + "redoc": f"{server.public_url}{server.app.redoc_url}", + "openapi": f"{server.public_url}{server.app.openapi_url}", + }, + "agents": [agent.registration_info for agent in server.agents], + } diff --git a/src/supervaizer/studio_handshake.py b/src/supervaizer/studio_handshake.py new file mode 100644 index 0000000..e75ed4e --- /dev/null +++ b/src/supervaizer/studio_handshake.py @@ -0,0 +1,144 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +from __future__ import annotations + +from typing import Any + +from supervaizer.common import ApiSuccess, log +from supervaizer.server_config import _controller_key_fingerprint + + +def validate_registration_handshake(server: Any, result: ApiSuccess) -> None: + detail = result.detail if isinstance(result.detail, dict) else {} + response_object = detail.get("object") + if not isinstance(response_object, dict): + raise RuntimeError( + "Studio registration handshake failed: server.register response did not " + "include a response object. Studio-to-agent API key persistence could not " + "be verified." + ) + handshake = response_object.get("supervaizer_handshake") + if not isinstance(handshake, dict): + response_keys = sorted(str(key) for key in response_object.keys()) + raise RuntimeError( + "Studio registration handshake failed: server.register response did not " + "include supervaizer_handshake. Studio-to-agent API key persistence could " + "not be verified. Check that SUPERVAIZE_API_URL points to a Studio " + "instance that supports the Supervaizer v2 registration handshake. " + f"response_keys={response_keys}" + ) + if handshake.get("controller_api_key_match") is True: + apply_workspace_authorization_handshake(server, handshake) + log.info( + "[Server launch] Studio registration handshake verified " + f"server_id={handshake.get('server_id')} " + f"controller_key_fingerprint={_controller_key_fingerprint(server.api_key)}" + ) + return + raise RuntimeError( + "Studio registration handshake failed: Studio did not persist the controller API key " + f"for server_id={handshake.get('server_id')}. " + f"controller_key_fingerprint={_controller_key_fingerprint(server.api_key)} " + f"studio_fingerprint={handshake.get('stored_controller_api_key_fingerprint')} " + f"reason={handshake.get('reason')}" + ) + + +def validate_studio_a2a_workspace_authorization(server: Any) -> None: + if not server.a2a_endpoints or server.supervisor_account is None: + return + if server.workspace_authorization.enabled: + return + raise RuntimeError( + "Studio-registered Supervaizer v2 A2A requires workspace authorization. " + "Set SUPERVAIZER_WORKSPACE_AUTH_REQUIRED=true and configure " + "SUPERVAIZER_WORKSPACE_AUTH_ISSUER plus either " + "SUPERVAIZER_WORKSPACE_AUTH_PUBLIC_KEY or SUPERVAIZER_WORKSPACE_AUTH_JWKS_URL." + ) + + +def apply_workspace_authorization_handshake( + server: Any, handshake: dict[str, Any] +) -> None: + if not server.workspace_authorization.enabled: + return + + workspace_authorization = handshake.get("workspace_authorization") + if not isinstance(workspace_authorization, dict): + raise RuntimeError( + "Studio registration handshake failed: workspace authorization is enabled " + "but supervaizer_handshake.workspace_authorization is missing." + ) + + audience = workspace_authorization.get("audience") + if not isinstance(audience, str) or not audience.strip(): + raise RuntimeError( + "Studio registration handshake failed: workspace authorization is enabled " + "but supervaizer_handshake.workspace_authorization.audience is missing." + ) + + configured_audience = server.workspace_authorization.audience + if configured_audience and configured_audience != audience: + raise RuntimeError( + "Studio registration handshake failed: configured workspace authorization " + "audience does not match Studio's server audience." + ) + + server.workspace_authorization = server.workspace_authorization.model_copy( + update={"audience": audience} + ) + agent_bindings = workspace_authorization.get("agents") + if not isinstance(agent_bindings, list): + raise RuntimeError( + "Studio registration handshake failed: workspace authorization is enabled " + "but supervaizer_handshake.workspace_authorization.agents is missing." + ) + apply_workspace_authorization_agent_bindings(server, agent_bindings) + + +def apply_workspace_authorization_agent_bindings( + server: Any, agent_bindings: list[Any] +) -> None: + bindings_by_slug: dict[str, str] = {} + for binding in agent_bindings: + if not isinstance(binding, dict): + raise RuntimeError( + "Studio registration handshake failed: workspace authorization agent " + "binding must be an object." + ) + agent_id = binding.get("id") + agent_slug = binding.get("slug") + if not isinstance(agent_id, str) or not agent_id.strip(): + raise RuntimeError( + "Studio registration handshake failed: workspace authorization agent " + "binding is missing id." + ) + if not isinstance(agent_slug, str) or not agent_slug.strip(): + raise RuntimeError( + "Studio registration handshake failed: workspace authorization agent " + "binding is missing slug." + ) + bindings_by_slug[agent_slug] = agent_id + + missing_agents = [] + for agent in server.agents: + studio_agent_id = bindings_by_slug.get(agent.slug) + if not studio_agent_id: + missing_agents.append(agent.slug) + continue + if agent.server_agent_id and agent.server_agent_id != studio_agent_id: + raise RuntimeError( + "Studio registration handshake failed: workspace authorization agent " + f"id mismatch for slug={agent.slug}." + ) + agent.server_agent_id = studio_agent_id + + if missing_agents: + raise RuntimeError( + "Studio registration handshake failed: workspace authorization did not " + f"return Studio agent id(s) for slug(s): {', '.join(missing_agents)}" + ) diff --git a/tests/test_server_refactor_modules.py b/tests/test_server_refactor_modules.py new file mode 100644 index 0000000..288db8b --- /dev/null +++ b/tests/test_server_refactor_modules.py @@ -0,0 +1,143 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +from __future__ import annotations + +from typing import Any + +import pytest + +import supervaizer.scheduled_steps as scheduled_steps +import supervaizer.server as server_module +import supervaizer.server_config as server_config +import supervaizer.server_info as server_info +from supervaizer import Server +from supervaizer.common import ApiSuccess +from supervaizer.server_registration import build_server_registration_info +from supervaizer.studio_handshake import validate_registration_handshake + + +def test_server_module_reexports_scheduled_step_helpers() -> None: + assert ( + server_module._execute_scheduled_method + is scheduled_steps._execute_scheduled_method + ) + assert ( + server_module._run_scheduled_step_loop + is scheduled_steps._run_scheduled_step_loop + ) + + +def test_server_module_reexports_config_helpers() -> None: + assert server_module._env_bool is server_config._env_bool + assert ( + server_module._controller_key_fingerprint + is server_config._controller_key_fingerprint + ) + + +def test_server_module_reexports_server_info_helpers() -> None: + assert server_module.ServerInfo is server_info.ServerInfo + assert ( + server_module.get_server_info_from_storage + is server_info.get_server_info_from_storage + ) + assert ( + server_module.get_server_info_from_live is server_info.get_server_info_from_live + ) + + +def test_execute_scheduled_method_calls_dotted_function() -> None: + result = scheduled_steps._execute_scheduled_method( + "tests.test_server_refactor_modules._scheduled_step_target", + {"value": "ok"}, + ) + + assert result == "scheduled-ok" + + +def test_resolve_workspace_authorization_settings_reads_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("SUPERVAIZER_WORKSPACE_AUTH_REQUIRED", "yes") + monkeypatch.setenv("SUPERVAIZER_WORKSPACE_AUTH_ISSUER", "https://studio.test") + monkeypatch.setenv("SUPERVAIZER_WORKSPACE_AUTH_AUDIENCE", "server-audience") + monkeypatch.setenv("SUPERVAIZER_WORKSPACE_AUTH_JWKS_URL", "https://jwks.test") + monkeypatch.setenv("SUPERVAIZER_WORKSPACE_AUTH_LEEWAY_SECONDS", "9") + + settings = server_config._resolve_workspace_authorization_settings(None) + + assert settings.enabled is True + assert settings.issuer == "https://studio.test" + assert settings.audience == "server-audience" + assert settings.jwks_url == "https://jwks.test" + assert settings.leeway_seconds == 9 + + +def test_get_server_info_from_live_uses_server_start_time( + server_fixture: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("SUPERVAIZER_ENVIRONMENT", "module-test") + object.__setattr__(server_fixture, "_start_time", 123.0) + + info = server_info.get_server_info_from_live(server_fixture) + + assert info.start_time == 123.0 + assert info.environment == "module-test" + assert info.agents == [ + { + "name": server_fixture.agents[0].name, + "description": server_fixture.agents[0].description, + "version": server_fixture.agents[0].version, + "api_path": server_fixture.agents[0].path, + "slug": server_fixture.agents[0].slug, + "instructions_path": server_fixture.agents[0].instructions_path, + } + ] + + +def test_server_info_storage_round_trip( + server_fixture: Server, + storage_manager: Any, +) -> None: + storage_manager.reset_storage() + + server_info.save_server_info_to_storage(server_fixture) + stored_info = server_info.get_server_info_from_storage() + + assert stored_info is not None + assert stored_info.id == server_info.SERVER_INFO_ID + assert stored_info.host == server_fixture.host + + +def test_registration_builder_matches_server_property(server_fixture: Server) -> None: + assert ( + build_server_registration_info(server_fixture) + == server_fixture.registration_info + ) + + +def test_validate_registration_handshake_function_accepts_key_match( + server_fixture: Server, +) -> None: + result = ApiSuccess( + message="POST Event SERVER_REGISTER sent", + detail={ + "object": { + "supervaizer_handshake": { + "server_id": "server-1", + "controller_api_key_match": True, + } + } + }, + ) + + validate_registration_handshake(server_fixture, result) + + +def _scheduled_step_target(value: str) -> str: + return f"scheduled-{value}"