From 3103fff8d33d9d52abd4eea18ff9a50d31de0468 Mon Sep 17 00:00:00 2001 From: OpenHands Bot Date: Wed, 16 Sep 2026 15:23:23 -0400 Subject: [PATCH 1/6] Add newly released LLM models to verified lists (#5098) Co-authored-by: openhands --- openhands-sdk/openhands/sdk/llm/utils/verified_models.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openhands-sdk/openhands/sdk/llm/utils/verified_models.py b/openhands-sdk/openhands/sdk/llm/utils/verified_models.py index d3d7b9261a..3a0cc89e14 100644 --- a/openhands-sdk/openhands/sdk/llm/utils/verified_models.py +++ b/openhands-sdk/openhands/sdk/llm/utils/verified_models.py @@ -5,7 +5,10 @@ "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5", + "gpt-5.5-pro", "gpt-5.4", + "gpt-5.4-pro", + "gpt-5.4-mini", "gpt-5.2", "gpt-5.2-codex", "gpt-5.3-codex", @@ -126,6 +129,8 @@ "qwen3-max", "qwen3.8-flash", "qwen3.7-flash", + "qwen3.6-flash", + "qwen3.5-flash", "qwen3-coder-480b", "qwen3-coder-next", "qwen3-coder-plus", @@ -146,7 +151,9 @@ "gpt-6-astra", "gpt-5.6", "gpt-5.5", + "gpt-5.5-pro", "gpt-5.4", + "gpt-5.4-pro", "gpt-5.2", "gpt-5.2-codex", "gpt-5.3-codex", @@ -198,6 +205,8 @@ "qwen3-max", "qwen3.8-flash", "qwen3.7-flash", + "qwen3.6-flash", + "qwen3.5-flash", "qwen3-coder-480b", "qwen3-coder-next", "qwen3-coder-plus", From 3ff6924d8564b3d47a22a6c7e71377a701ae014f Mon Sep 17 00:00:00 2001 From: Robert Brennan Date: Wed, 16 Sep 2026 15:59:06 -0400 Subject: [PATCH 2/6] feat(agent-server): add docker runtime mode for per-conversation containers (#3403) Co-authored-by: openhands --- .../openhands/agent_server/api.py | 40 +- .../openhands/agent_server/config.py | 7 + .../agent_server/conversation_registry.py | 74 ++++ .../agent_server/conversation_router.py | 3 + .../agent_server/conversation_service.py | 65 +++- .../agent_server/docker_runtime/__init__.py | 1 + .../agent_server/docker_runtime/mediation.py | 112 ++++++ .../docker_runtime/provisioning.py | 163 +++++++++ .../agent_server/docker_runtime/proxy.py | 253 +++++++++++++ .../agent_server/docker_runtime/registry.py | 319 ++++++++++++++++ .../agent_server/docker_runtime/routers.py | 345 ++++++++++++++++++ .../openhands/agent_server/init_router.py | 13 + .../agent_server/server_details_router.py | 13 +- .../openhands/agent_server/sockets.py | 7 +- tests/agent_server/docker_runtime/__init__.py | 0 .../docker_runtime/test_mediation.py | 136 +++++++ .../docker_runtime/test_provisioning.py | 92 +++++ .../agent_server/docker_runtime/test_proxy.py | 134 +++++++ .../docker_runtime/test_registry.py | 145 ++++++++ .../docker_runtime/test_routes.py | 222 +++++++++++ tests/agent_server/test_api.py | 31 ++ .../agent_server/test_conversation_service.py | 103 ++++++ tests/agent_server/test_event_streaming.py | 2 +- .../test_server_details_router.py | 18 + 24 files changed, 2265 insertions(+), 33 deletions(-) create mode 100644 openhands-agent-server/openhands/agent_server/conversation_registry.py create mode 100644 openhands-agent-server/openhands/agent_server/docker_runtime/__init__.py create mode 100644 openhands-agent-server/openhands/agent_server/docker_runtime/mediation.py create mode 100644 openhands-agent-server/openhands/agent_server/docker_runtime/provisioning.py create mode 100644 openhands-agent-server/openhands/agent_server/docker_runtime/proxy.py create mode 100644 openhands-agent-server/openhands/agent_server/docker_runtime/registry.py create mode 100644 openhands-agent-server/openhands/agent_server/docker_runtime/routers.py create mode 100644 tests/agent_server/docker_runtime/__init__.py create mode 100644 tests/agent_server/docker_runtime/test_mediation.py create mode 100644 tests/agent_server/docker_runtime/test_provisioning.py create mode 100644 tests/agent_server/docker_runtime/test_proxy.py create mode 100644 tests/agent_server/docker_runtime/test_registry.py create mode 100644 tests/agent_server/docker_runtime/test_routes.py diff --git a/openhands-agent-server/openhands/agent_server/api.py b/openhands-agent-server/openhands/agent_server/api.py index 6ef5d14886..aeb154385a 100644 --- a/openhands-agent-server/openhands/agent_server/api.py +++ b/openhands-agent-server/openhands/agent_server/api.py @@ -26,7 +26,13 @@ Config, get_default_config, ) -from openhands.agent_server.conversation_router import conversation_router +from openhands.agent_server.conversation_registry import ( + create_conversation_registry, +) +from openhands.agent_server.conversation_router import ( + conversation_catalog_router, + conversation_router, +) from openhands.agent_server.conversation_service import ( CredentialBindingActivationRequired, get_default_conversation_service, @@ -39,7 +45,6 @@ check_workspace_session, ) from openhands.agent_server.desktop_router import desktop_router -from openhands.agent_server.event_router import event_router from openhands.agent_server.file_router import file_discovery_router, file_router from openhands.agent_server.git_router import git_router from openhands.agent_server.hooks_router import hooks_router @@ -60,16 +65,13 @@ from openhands.agent_server.provider_connections_router import ( provider_connections_router, ) -from openhands.agent_server.runtime_router import create_runtime_router from openhands.agent_server.server_details_router import ( get_server_info, mark_initialization_complete, server_details_router, ) -from openhands.agent_server.session_socket import session_router from openhands.agent_server.settings_router import settings_router from openhands.agent_server.skills_router import skills_router -from openhands.agent_server.sockets import sockets_router from openhands.agent_server.sub_agents_router import sub_agents_router from openhands.agent_server.telemetry import ( build_telemetry_sink, @@ -92,7 +94,6 @@ from openhands.agent_server.tool_router import tool_router from openhands.agent_server.vscode_router import vscode_router from openhands.agent_server.vscode_service import get_vscode_service -from openhands.agent_server.workspace_router import workspace_router from openhands.agent_server.workspaces_router import workspaces_router from openhands.sdk.logger import DEBUG, get_logger from openhands.sdk.utils.redact import sanitize_dict @@ -160,6 +161,10 @@ async def api_lifespan(api: FastAPI) -> AsyncIterator[None]: config: Config = api.state.config deferred = config.deferred_init + conversation_registry = getattr( + api.state, "conversation_registry", None + ) or create_conversation_registry(config) + api.state.conversation_registry = conversation_registry # Deferred pods boot with telemetry disabled and are rebuilt by # InitService, so they emit `server_started` there instead. @@ -254,6 +259,11 @@ async def stop_tool_preload_service(): bash_svc = get_default_bash_event_service() api.state.bash_event_service = bash_svc + conversation_registry.configure_service(service) + # Runtime cleanup must precede external-catalog recovery so stale + # runtime owners cannot lose their expired leases to the outer service. + await conversation_registry.start() + async with service: api.state.conversation_service = service @@ -273,6 +283,7 @@ async def stop_tool_preload_service(): try: yield finally: + await conversation_registry.shutdown() if retention_task is not None: retention_task.cancel() with suppress(asyncio.CancelledError): @@ -388,6 +399,7 @@ def _find_http_exception(exc: BaseExceptionGroup) -> HTTPException | None: def _add_api_routes(app: FastAPI) -> None: """Add all API routes to the FastAPI application.""" + conversation_registry = app.state.conversation_registry app.include_router(server_details_router) # The /api/init endpoint bypasses both the session-key auth and the @@ -413,8 +425,10 @@ def _add_api_routes(app: FastAPI) -> None: api_router = APIRouter(prefix="/api", dependencies=dependencies) api_router.include_router(file_discovery_router) - api_router.include_router(create_runtime_router()) - api_router.include_router(event_router) + # Collection routes must precede runtime catch-alls such as + # ``/conversations/{conversation_id}``. + api_router.include_router(conversation_catalog_router) + conversation_registry.add_execution_routes(api_router) api_router.include_router(conversation_router) api_router.include_router(credential_binding_router) api_router.include_router(tool_router) @@ -438,8 +452,6 @@ def _add_api_routes(app: FastAPI) -> None: # /api/auth/* mints workspace cookies and requires the header to bootstrap, # so it lives under the header-only auth group. api_router.include_router(auth_router) - app.include_router(api_router) - app.include_router(openai_router, dependencies=[Depends(check_openai_api_key)]) # Workspace static-file routes get their own auth group that accepts @@ -450,12 +462,11 @@ def _add_api_routes(app: FastAPI) -> None: workspace_api_router = APIRouter( prefix="/api", dependencies=[Depends(check_workspace_session)] ) - workspace_api_router.include_router(workspace_router) + workspace_api_router.include_router(conversation_registry.workspace_router) app.include_router(workspace_api_router) + app.include_router(api_router) - app.include_router(sockets_router) - - app.include_router(session_router) + app.include_router(conversation_registry.sockets_router) def _setup_static_files(app: FastAPI, config: Config) -> None: @@ -683,6 +694,7 @@ def create_app(config: Config | None = None) -> FastAPI: config = get_default_config() app = _create_fastapi_instance(config) app.state.config = config + app.state.conversation_registry = create_conversation_registry(config) _add_api_routes(app) _setup_static_files(app, config) diff --git a/openhands-agent-server/openhands/agent_server/config.py b/openhands-agent-server/openhands/agent_server/config.py index f92192194c..571e5f5bec 100644 --- a/openhands-agent-server/openhands/agent_server/config.py +++ b/openhands-agent-server/openhands/agent_server/config.py @@ -346,6 +346,13 @@ class Config(BaseModel): "The URL where this agent server instance is available externally" ), ) + conversation_runtime: Literal["local", "docker"] = "local" + conversation_image: str = "ghcr.io/openhands/agent-server:latest-python" + conversation_container_memory: str | None = "4g" + conversation_container_cpus: float | None = Field(default=2.0, gt=0) + conversation_container_pids_limit: int | None = Field(default=512, gt=0) + conversation_container_startup_timeout: float = Field(default=120, gt=0) + acp_skill_sourcing: ACPSkillSourcing = Field( default="native", description=( diff --git a/openhands-agent-server/openhands/agent_server/conversation_registry.py b/openhands-agent-server/openhands/agent_server/conversation_registry.py new file mode 100644 index 0000000000..121ac24e0f --- /dev/null +++ b/openhands-agent-server/openhands/agent_server/conversation_registry.py @@ -0,0 +1,74 @@ +"""Select and manage the configured conversation runtime.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from fastapi import APIRouter + +from openhands.agent_server.config import Config + + +if TYPE_CHECKING: + from openhands.agent_server.conversation_service import ConversationService + + +class ConversationRegistry: + """Route and lifecycle adapter for host-local conversations.""" + + def __init__(self, config: Config) -> None: + self.config = config + + def configure_service(self, service: ConversationService) -> None: + """Connect runtime-specific persistence to the shared catalog.""" + + async def start(self) -> None: + """Start resources owned by this registry.""" + + async def shutdown(self) -> None: + """Stop resources owned by this registry.""" + + def add_execution_routes(self, router: APIRouter) -> None: + from openhands.agent_server.event_router import event_router + from openhands.agent_server.runtime_router import create_runtime_router + + router.include_router(create_runtime_router()) + router.include_router(event_router) + + @property + def workspace_router(self) -> APIRouter: + from openhands.agent_server.workspace_router import workspace_router + + return workspace_router + + @property + def conversation_sockets_router(self) -> APIRouter: + from openhands.agent_server.sockets import conversation_sockets_router + + return conversation_sockets_router + + @property + def session_sockets_router(self) -> APIRouter: + from openhands.agent_server.session_socket import session_router + + return session_router + + @property + def sockets_router(self) -> APIRouter: + from openhands.agent_server.sockets import bash_sockets_router + + router = APIRouter() + router.include_router(self.conversation_sockets_router) + router.include_router(self.session_sockets_router) + router.include_router(bash_sockets_router) + return router + + +def create_conversation_registry(config: Config) -> ConversationRegistry: + if config.conversation_runtime == "docker": + from openhands.agent_server.docker_runtime.registry import ( + DockerConversationRegistry, + ) + + return DockerConversationRegistry(config) + return ConversationRegistry(config) diff --git a/openhands-agent-server/openhands/agent_server/conversation_router.py b/openhands-agent-server/openhands/agent_server/conversation_router.py index d4c5f19099..91a8e8ef54 100644 --- a/openhands-agent-server/openhands/agent_server/conversation_router.py +++ b/openhands-agent-server/openhands/agent_server/conversation_router.py @@ -63,6 +63,7 @@ from openhands.tools.preset.default import get_default_tools +conversation_catalog_router = APIRouter(prefix="/conversations", tags=["Conversations"]) conversation_router = APIRouter(prefix="/conversations", tags=["Conversations"]) # Examples @@ -88,6 +89,7 @@ # Read methods +@conversation_catalog_router.get("/search", include_in_schema=False) @conversation_router.get("/search") async def search_conversations( page_id: Annotated[ @@ -128,6 +130,7 @@ async def search_conversations( return page +@conversation_catalog_router.get("/count", include_in_schema=False) @conversation_router.get("/count") async def count_conversations( status: Annotated[ diff --git a/openhands-agent-server/openhands/agent_server/conversation_service.py b/openhands-agent-server/openhands/agent_server/conversation_service.py index d5f0988895..8d1e78d912 100644 --- a/openhands-agent-server/openhands/agent_server/conversation_service.py +++ b/openhands-agent-server/openhands/agent_server/conversation_service.py @@ -693,6 +693,7 @@ class ConversationService: webhook_specs: list[WebhookSpec] = field(default_factory=list) session_api_key: str | None = field(default=None) cipher: Cipher | None = None + runtime_cipher_resolver: Callable[[UUID], Cipher] | None = None mcp_tool_provider: MCPToolProvider | None = None secrets_store: FileSecretsStore | None = None owner_instance_id: str = field(default_factory=lambda: uuid4().hex) @@ -703,6 +704,7 @@ class ConversationService: default=Path("/tmp/conversation-worktrees") ) acp_skill_sourcing: ACPSkillSourcing = "native" + sync_external_catalog: bool = False _event_services: dict[UUID, EventService] | None = field(default=None, init=False) _conversation_records: dict[UUID, _ConversationRecord] = field( default_factory=dict, init=False @@ -726,16 +728,23 @@ class ConversationService: default_factory=dict, init=False ) - def _load_catalog_sync(self) -> dict[UUID, _ConversationRecord]: + def _load_catalog_sync( + self, conversation_id: UUID | None = None + ) -> dict[UUID, _ConversationRecord]: records: dict[UUID, _ConversationRecord] = {} - for conversation_dir in self.conversations_dir.iterdir(): + directories = ( + [self.conversations_dir / conversation_id.hex] + if conversation_id is not None + else self.conversations_dir.iterdir() + ) + for conversation_dir in directories: meta_file = conversation_dir / "meta.json" if not meta_file.exists(): continue try: stored = StoredConversation.model_validate_json( meta_file.read_text(), - context={"cipher": self.cipher}, + context={"cipher": self._cipher_for(UUID(conversation_dir.name))}, ) execution_status = ConversationExecutionStatus.IDLE base_state_file = conversation_dir / BASE_STATE @@ -778,13 +787,19 @@ def _base_state_path( record.base_state_path = path return path + def _cipher_for(self, conversation_id: UUID) -> Cipher | None: + if self.runtime_cipher_resolver is not None: + return self.runtime_cipher_resolver(conversation_id) + return self.cipher + def _load_persisted_state_sync( self, conversation_id: UUID ) -> ConversationState | None: base_state_file = self.conversations_dir / conversation_id.hex / BASE_STATE if not base_state_file.exists(): return None - context = {"cipher": self.cipher} if self.cipher else None + cipher = self._cipher_for(conversation_id) + context = {"cipher": cipher} if cipher else None return ConversationState.model_validate_json( base_state_file.read_text(), context=context ) @@ -1093,16 +1108,34 @@ async def _refresh_execution_statuses(self) -> None: record.cached_info = None record.state_signature = signature - async def _reconcile_active_records(self) -> None: - """Fill catalog entries for services injected outside normal startup. - - Normal service lifecycle paths maintain the catalog themselves. This - small reconciliation keeps direct embedders and existing test fixtures - that populate ``_event_services`` compatible. - """ + async def _reconcile_active_records( + self, conversation_id: UUID | None = None + ) -> None: + """Discover externally persisted records and injected live services.""" event_services = self._event_services if event_services is None: raise ValueError("inactive_service") + if self.sync_external_catalog: + disk_records = await asyncio.to_thread( + self._load_catalog_sync, conversation_id + ) + stale_ids = ( + {conversation_id} + if conversation_id is not None + else set(self._conversation_records) + ) - set(disk_records) + for record_id in stale_ids: + event_service = event_services.get(record_id) + if event_service is None or not event_service.is_open(): + self._conversation_records.pop(record_id, None) + for record_id, record in disk_records.items(): + event_service = event_services.get(record_id) + if event_service is not None and event_service.is_open(): + continue + existing = self._conversation_records.setdefault(record_id, record) + if existing.stored != record.stored: + existing.stored = record.stored + existing.cached_info = None for conversation_id, event_service in event_services.items(): if conversation_id in self._conversation_records: continue @@ -1237,6 +1270,8 @@ async def _get_or_load_event_service_locked( async def get_conversation(self, conversation_id: UUID) -> ConversationInfo | None: if self._event_services is None: raise ValueError("inactive_service") + if self.sync_external_catalog: + await self._reconcile_active_records(conversation_id) record = self._conversation_records.get(conversation_id) if record is None: event_service = self._event_services.get(conversation_id) @@ -1615,7 +1650,12 @@ async def _start_conversation( # Profile resolution and the load_memory stamp must happen before # _prepare_request_workspace (which asserts request.agent is not None) # and before model_dump so the resolved agent is captured in request_data. - launched_agent_profile: LaunchedAgentProfile | None = None + runtime_profile = os.getenv("OH_RUNTIME_LAUNCHED_PROFILE") + launched_agent_profile = ( + LaunchedAgentProfile.model_validate_json(runtime_profile) + if runtime_profile + else None + ) from openhands.agent_server.persistence import ( PersistedSettings, @@ -2386,6 +2426,7 @@ def get_instance(cls, config: Config) -> "ConversationService": conversation_idle_ttl_seconds=config.conversation_idle_ttl_seconds, conversation_worktree_root=config.conversation_worktree_root, acp_skill_sourcing=config.acp_skill_sourcing, + sync_external_catalog=False, ) async def _start_event_service( diff --git a/openhands-agent-server/openhands/agent_server/docker_runtime/__init__.py b/openhands-agent-server/openhands/agent_server/docker_runtime/__init__.py new file mode 100644 index 0000000000..7c9c417831 --- /dev/null +++ b/openhands-agent-server/openhands/agent_server/docker_runtime/__init__.py @@ -0,0 +1 @@ +"""Per-conversation Docker runtime support.""" diff --git a/openhands-agent-server/openhands/agent_server/docker_runtime/mediation.py b/openhands-agent-server/openhands/agent_server/docker_runtime/mediation.py new file mode 100644 index 0000000000..2a470cf4dd --- /dev/null +++ b/openhands-agent-server/openhands/agent_server/docker_runtime/mediation.py @@ -0,0 +1,112 @@ +"""Prepare a conversation request for an isolated agent-server.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Mapping +from typing import Any + +from pydantic import SecretStr + +from openhands.agent_server.config import Config +from openhands.agent_server.conversation_service import ( + _resolve_agent_from_profile, + _with_load_memory, +) +from openhands.agent_server.docker_runtime.provisioning import RuntimeIdentity +from openhands.agent_server.persistence import PersistedSettings, get_settings_store +from openhands.sdk.agent.base import AgentBase +from openhands.sdk.conversation.request import StartConversationRequest +from openhands.sdk.conversation.secret_registry import SecretRegistry +from openhands.sdk.profiles.agent_profile import LaunchedAgentProfile +from openhands.sdk.secret import SecretSource, SecretValue, StaticSecret +from openhands.sdk.settings.model import validate_agent_settings + + +def materialize_secrets( + secrets: Mapping[str, SecretValue], +) -> dict[str, SecretSource]: + """Resolve the SDK's secret sources before crossing a runtime boundary.""" + registry = SecretRegistry() + registry.update_secrets(secrets) + return { + name: StaticSecret( + value=SecretStr(value) + if (value := registry.get_secret_value(name)) + else None, + description=source.description, + ) + for name, source in registry.secret_sources.items() + } + + +def _materialize_agent_context(agent: AgentBase) -> AgentBase: + context = agent.agent_context + if context is None or not context.secrets: + return agent + return agent.model_copy( + update={ + "agent_context": context.model_copy( + update={"secrets": materialize_secrets(context.secrets)} + ) + } + ) + + +async def prepare_start( + body: dict[str, Any], config: Config +) -> tuple[StartConversationRequest, LaunchedAgentProfile | None]: + body = { + name: value + for name, value in body.items() + if value is not None or name not in {"agent", "agent_settings"} + } + context = {"cipher": config.cipher} if body.get("secrets_encrypted") else None + if body.get("agent_settings") is not None: + settings = validate_agent_settings(body["agent_settings"], context=context) + body = {**body, "agent": settings.create_agent(), "agent_settings": None} + request = StartConversationRequest.model_validate(body, context=context) + + try: + settings = await asyncio.to_thread(get_settings_store(config).load) + except (OSError, PermissionError): + settings = None + settings = settings or PersistedSettings() + launched = None + if request.agent_profile_id is not None: + agent, launched, allowed = await asyncio.to_thread( + _resolve_agent_from_profile, + request.agent_profile_id, + config.cipher, + settings.agent_settings.mcp_config, + acp_skill_sourcing=config.acp_skill_sourcing, + ) + secrets = request.secrets + if allowed is not None: + secrets = { + name: value for name, value in secrets.items() if name in allowed + } + request = request.model_copy( + update={"agent": agent, "agent_profile_id": None, "secrets": secrets} + ) + + context_settings = settings.agent_settings.agent_context + if context_settings is not None and context_settings.load_memory: + request = request.model_copy(update={"agent": _with_load_memory(request.agent)}) + request = request.model_copy( + update={ + "agent": await asyncio.to_thread(_materialize_agent_context, request.agent), + "secrets": await asyncio.to_thread(materialize_secrets, request.secrets), + } + ) + return request, launched + + +def serialize_start( + request: StartConversationRequest, identity: RuntimeIdentity +) -> dict[str, Any]: + payload = request.model_dump( + mode="json", context={"cipher": identity.cipher}, exclude={"agent_profile_id"} + ) + payload["secrets_encrypted"] = True + return payload diff --git a/openhands-agent-server/openhands/agent_server/docker_runtime/provisioning.py b/openhands-agent-server/openhands/agent_server/docker_runtime/provisioning.py new file mode 100644 index 0000000000..0e3a1883a6 --- /dev/null +++ b/openhands-agent-server/openhands/agent_server/docker_runtime/provisioning.py @@ -0,0 +1,163 @@ +"""Persist the credentials owned by one conversation container.""" + +from __future__ import annotations + +import os +import secrets +import tempfile +from pathlib import Path +from stat import S_ISREG +from uuid import UUID + +from filelock import FileLock +from pydantic import BaseModel, ConfigDict, SecretStr, field_serializer, field_validator + +from openhands.agent_server.config import Config +from openhands.agent_server.persistence.store import _get_persistence_dir +from openhands.sdk.profiles.agent_profile import LaunchedAgentProfile +from openhands.sdk.utils.cipher import Cipher +from openhands.sdk.utils.pydantic_secrets import serialize_secret, validate_secret + + +class RuntimeIdentity(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + conversation_id: UUID + api_key: SecretStr + encryption_key: SecretStr + workspace_path: Path + launched_agent_profile: LaunchedAgentProfile | None = None + + @field_serializer("api_key", "encryption_key") + def serialize_key(self, value, info): + return serialize_secret(value, info) + + @field_validator("api_key", "encryption_key") + @classmethod + def validate_key(cls, value, info): + result = validate_secret(value, info) + if result is None: + raise ValueError("Runtime identity cannot be decrypted") + return result + + @property + def cipher(self) -> Cipher: + return Cipher(self.encryption_key.get_secret_value()) + + +class RuntimeProvisioningStore: + def __init__(self, config: Config): + if config.cipher is None: + raise ValueError("Docker runtime requires OH_SECRET_KEY") + self.config = config + self.cipher = config.cipher + persistence = _get_persistence_dir(config).resolve() + self.control_root = persistence / "runtime-control" + self.data_root = persistence / "runtime-data" + self._identities: dict[UUID, tuple[tuple[int, int, int], RuntimeIdentity]] = {} + for root in (self.control_root, self.data_root): + if root.is_symlink(): + raise ValueError("Runtime storage roots must not be symlinks") + root.mkdir(parents=True, mode=0o700, exist_ok=True) + root.chmod(0o700) + + def manifest_path(self, conversation_id: UUID) -> Path: + return self.control_root / f"{conversation_id.hex}.json" + + def runtime_dir(self, conversation_id: UUID) -> Path: + return self.direct_child(self.data_root, conversation_id.hex) + + @staticmethod + def direct_child(root: Path, name: str) -> Path: + if root.is_symlink(): + raise ValueError("Runtime storage root must not be a symlink") + child = root / name + resolved = child.resolve() + if child.is_symlink() or resolved.parent != root.resolve(): + raise ValueError("Runtime mount must not follow a symlink") + return resolved + + def load(self, conversation_id: UUID) -> RuntimeIdentity: + path = self.manifest_path(conversation_id) + try: + manifest_stat = path.lstat() + except OSError: + self._identities.pop(conversation_id, None) + raise ValueError("Conversation runtime identity is unavailable") from None + if not S_ISREG(manifest_stat.st_mode): + self._identities.pop(conversation_id, None) + raise ValueError("Conversation runtime identity is unavailable") + signature = ( + manifest_stat.st_ino, + manifest_stat.st_mtime_ns, + manifest_stat.st_size, + ) + cached = self._identities.get(conversation_id) + if cached is not None and cached[0] == signature: + return cached[1] + identity = RuntimeIdentity.model_validate_json( + path.read_text(), context={"cipher": self.cipher} + ) + if identity.conversation_id != conversation_id: + raise ValueError("Runtime identity does not match conversation") + self._identities[conversation_id] = (signature, identity) + return identity + + def create( + self, conversation_id: UUID, workspace_path: Path | None = None + ) -> RuntimeIdentity: + path = self.manifest_path(conversation_id) + with FileLock(str(path) + ".lock"): + if path.exists(): + identity = self.load(conversation_id) + if ( + workspace_path is not None + and workspace_path.resolve() != identity.workspace_path + ): + raise ValueError("A conversation cannot change workspaces") + return identity + conversation_dir = self.direct_child( + self.config.conversations_path, conversation_id.hex + ) + if (conversation_dir / "meta.json").exists() or ( + conversation_dir / "base_state.json" + ).exists(): + raise ValueError( + "An existing local conversation cannot become a Docker runtime" + ) + if workspace_path is None: + workspace_path = self.direct_child( + self.runtime_dir(conversation_id), "workspace" + ) + workspace_path.mkdir(parents=True, mode=0o700, exist_ok=True) + else: + if not workspace_path.is_absolute(): + raise ValueError("Conversation workspace must be absolute") + if workspace_path.is_symlink() or not workspace_path.is_dir(): + raise ValueError("Conversation workspace must be a real directory") + workspace_path = workspace_path.resolve() + identity = RuntimeIdentity( + conversation_id=conversation_id, + api_key=SecretStr(secrets.token_urlsafe(32)), + encryption_key=SecretStr(secrets.token_urlsafe(32)), + workspace_path=workspace_path, + ) + self._save(identity) + return identity + + def save(self, identity: RuntimeIdentity) -> None: + with FileLock(str(self.manifest_path(identity.conversation_id)) + ".lock"): + self._save(identity) + + def _save(self, identity: RuntimeIdentity) -> None: + payload = identity.model_dump_json(context={"cipher": self.cipher}) + fd, temporary = tempfile.mkstemp(dir=self.control_root) + try: + with os.fdopen(fd, "w") as stream: + stream.write(payload) + os.replace(temporary, self.manifest_path(identity.conversation_id)) + stat = self.manifest_path(identity.conversation_id).stat() + signature = (stat.st_ino, stat.st_mtime_ns, stat.st_size) + self._identities[identity.conversation_id] = (signature, identity) + finally: + Path(temporary).unlink(missing_ok=True) diff --git a/openhands-agent-server/openhands/agent_server/docker_runtime/proxy.py b/openhands-agent-server/openhands/agent_server/docker_runtime/proxy.py new file mode 100644 index 0000000000..579d736702 --- /dev/null +++ b/openhands-agent-server/openhands/agent_server/docker_runtime/proxy.py @@ -0,0 +1,253 @@ +"""HTTP and WebSocket forwarding to authenticated conversation containers.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from contextlib import AsyncExitStack +from typing import Protocol +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +import httpx +import websockets +from fastapi import HTTPException, status +from starlette.requests import Request +from starlette.responses import StreamingResponse +from starlette.websockets import WebSocket, WebSocketDisconnect + +from openhands.sdk.logger import get_logger + + +class ProxyTarget(Protocol): + @property + def host(self) -> str: ... + + @property + def api_key(self) -> str | None: ... + + +logger = get_logger(__name__) + +# Hop-by-hop headers (RFC 7230) — must not be forwarded by a proxy. +_HOP_BY_HOP_HEADERS = frozenset( + { + "authorization", + "x-session-api-key", + "cookie", + "set-cookie", + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + # ``host`` and ``content-length`` are recomputed by httpx; forwarding + # the original values causes spurious 400s when bodies are re-chunked. + "host", + "content-length", + } +) + +# Stream chunk size for request/response bodies. Set it explicitly so proxy +# behavior does not depend on an httpx implementation default. +_CHUNK_SIZE = 64 * 1024 + + +def _filter_headers(headers) -> dict[str, str]: + return {k: v for k, v in headers.items() if k.lower() not in _HOP_BY_HOP_HEADERS} + + +def strip_auth_query(path: str) -> str: + parsed = urlsplit(path) + query = urlencode( + [ + (key, value) + for key, value in parse_qsl(parsed.query, keep_blank_values=True) + if key.lower() + not in {"session_api_key", "x-session-api-key", "authorization"} + ] + ) + return urlunsplit( + (parsed.scheme, parsed.netloc, parsed.path, query, parsed.fragment) + ) + + +async def proxy_http( + request: Request, + workspace: ProxyTarget, + *, + upstream_path: str, + timeout: float | None = None, + body: bytes | None = None, +) -> StreamingResponse: + """Forward ``request`` to the per-conversation container. + + Args: + request: Incoming Starlette request on the outer agent-server. + workspace: The proxy target for the target container. + upstream_path: Path (including any query string) on the inner + agent-server to forward to. Typically the same path the outer + server received, since the inner agent-server exposes the same + API surface. + timeout: Per-request timeout in seconds. ``None`` (the default) means + no read timeout — conversation event streams can be long-lived. + body: Replacement request body. By default the incoming body is streamed. + + Notes: + A fresh :class:`httpx.AsyncClient` is created per request. We avoid a + long-lived pool because the outer server can serve many concurrent + conversations and each one talks to a different upstream port — and + because making the client per-request keeps the lifespan/teardown + story trivial. + """ + if request.headers.get("x-expose-secrets"): + raise HTTPException(422, "Runtime secret exposure is unsupported") + url = workspace.host + strip_auth_query(upstream_path) + headers = _filter_headers(request.headers) + # If the client authenticated via the workspace-session cookie (used by + # iframe / img embeds that can't attach custom headers), there's no + # ``X-Session-API-Key`` on the inbound request — but the inner + # agent-server only knows about the header. Synthesize one from the + # workspace's stored key so the inner accepts the proxied request. + if workspace.api_key: + headers["X-Session-API-Key"] = workspace.api_key + + async def _request_body() -> AsyncIterator[bytes]: + if body is not None: + yield body + return + async for chunk in request.stream(): + if chunk: + yield chunk + + # Keep HTTPX's contexts open until StreamingResponse consumes the body. + stack = AsyncExitStack() + try: + client = await stack.enter_async_context( + httpx.AsyncClient( + timeout=httpx.Timeout(connect=10.0, read=timeout, write=30.0, pool=10.0) + ) + ) + upstream = await stack.enter_async_context( + client.stream(request.method, url, headers=headers, content=_request_body()) + ) + except BaseException as exc: + await stack.aclose() + if not isinstance(exc, httpx.HTTPError): + raise + logger.warning("Conversation upstream connection failed") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Conversation container unreachable", + ) from exc + + async def _response_body() -> AsyncIterator[bytes]: + try: + async for chunk in upstream.aiter_raw(chunk_size=_CHUNK_SIZE): + yield chunk + finally: + await stack.aclose() + + return StreamingResponse( + _response_body(), + status_code=upstream.status_code, + headers=_filter_headers(upstream.headers), + media_type=upstream.headers.get("content-type"), + ) + + +async def bridge_websocket( + client_ws: WebSocket, + workspace: ProxyTarget, + *, + upstream_path: str, +) -> None: + """Bridge a WebSocket session between the browser and an inner container. + + Precondition: ``client_ws`` MUST already be accepted by the caller. The + bridge does not call ``accept()`` itself because the outer server's + WebSocket-auth helper accepts on success and calling ``accept()`` a + second time would raise. + + Closure semantics: when either side closes (or errors), we close the + other side and return. No reconnect. + """ + upstream_url = workspace.host.replace("http://", "ws://").replace( + "https://", "wss://" + ) + strip_auth_query(upstream_path) + + # The local sockets router accepts auth via header / query param / first + # message. By the time we get here the outer has already accepted the + # socket — but the inner is a separate server that requires its own + # auth. Mint the inner-side ``X-Session-API-Key`` from the workspace's + # shared key. (If both outer and inner have no key requirement, the + # workspace.api_key is None and we send no header — that's fine.) + upstream_headers: dict[str, str] = {} + if workspace.api_key: + upstream_headers["X-Session-API-Key"] = workspace.api_key + + try: + async with websockets.connect( + upstream_url, + additional_headers=upstream_headers or None, + ) as upstream_ws: + await _bridge_websocket_loop(client_ws, upstream_ws) + except websockets.exceptions.InvalidStatus as exc: + logger.warning("Upstream WebSocket rejected (%s) to %s", exc, workspace.host) + # 1011 == "internal error"; closest match for an upstream HTTP failure + # since browsers can't see HTTP status codes from a failed upgrade. + await client_ws.close(code=1011) + except (OSError, websockets.exceptions.WebSocketException) as exc: + logger.warning( + "Upstream WebSocket connect failed to %s: %s", workspace.host, exc + ) + await client_ws.close(code=1011) + + +async def _bridge_websocket_loop(client_ws: WebSocket, upstream_ws) -> None: + async def _client_to_upstream() -> None: + try: + while True: + message = await client_ws.receive() + if message.get("type") == "websocket.disconnect": + return + if "bytes" in message and message["bytes"] is not None: + await upstream_ws.send(message["bytes"]) + elif "text" in message and message["text"] is not None: + await upstream_ws.send(message["text"]) + except WebSocketDisconnect: + return + + async def _upstream_to_client() -> None: + try: + async for message in upstream_ws: + if isinstance(message, (bytes, bytearray)): + await client_ws.send_bytes(bytes(message)) + else: + await client_ws.send_text(message) + except websockets.exceptions.ConnectionClosed: + return + + tasks = { + asyncio.create_task(_client_to_upstream()), + asyncio.create_task(_upstream_to_client()), + } + try: + done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + for task in done: + task.result() + finally: + for task in tasks: + if not task.done(): + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + try: + await upstream_ws.close() + except Exception as exc: + logger.debug("Upstream WebSocket close failed (%s)", type(exc).__name__) + try: + await client_ws.close() + except Exception as exc: + logger.debug("Client WebSocket close failed (%s)", type(exc).__name__) diff --git a/openhands-agent-server/openhands/agent_server/docker_runtime/registry.py b/openhands-agent-server/openhands/agent_server/docker_runtime/registry.py new file mode 100644 index 0000000000..c8c84ee00c --- /dev/null +++ b/openhands-agent-server/openhands/agent_server/docker_runtime/registry.py @@ -0,0 +1,319 @@ +"""Own one hardened agent-server container per conversation.""" + +from __future__ import annotations + +import asyncio +import hashlib +import os +import subprocess +import time +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING +from urllib.error import URLError +from urllib.request import urlopen +from uuid import UUID, uuid4 + +from openhands.agent_server.config import V1_SESSION_API_KEY_ENV, Config +from openhands.agent_server.conversation_registry import ConversationRegistry +from openhands.agent_server.docker_runtime.provisioning import RuntimeProvisioningStore +from openhands.agent_server.persistence.store import _get_persistence_dir +from openhands.sdk.logger import get_logger +from openhands.sdk.utils.command import execute_command, sanitized_env + + +if TYPE_CHECKING: + from fastapi import APIRouter + + from openhands.agent_server.conversation_service import ConversationService + + +logger = get_logger(__name__) + +_CONVERSATIONS_DIR = "/var/openhands/conversations" +_PERSISTENCE_DIR = "/var/openhands/.openhands" +_WORKSPACE_DIR = "/workspace" +_OWNER_LABEL = "ai.openhands.runtime-owner" + + +@dataclass(slots=True) +class ConversationContainer: + host: str + api_key: str + container_id: str + + def stop(self) -> None: + result = execute_command(["docker", "stop", self.container_id]) + if result.returncode != 0 and "No such container" not in result.stderr: + raise RuntimeError( + f"Failed to stop conversation container: {result.stderr}" + ) + + def is_running(self) -> bool: + result = execute_command( + ["docker", "inspect", "-f", "{{.State.Running}}", self.container_id] + ) + return result.returncode == 0 and result.stdout.strip() == "true" + + +class DockerConversationRegistry(ConversationRegistry): + def __init__(self, config: Config) -> None: + super().__init__(config) + paths = ( + f"{config.conversations_path.resolve()}\0" + f"{_get_persistence_dir(config).resolve()}" + ) + self.owner = hashlib.sha256(paths.encode()).hexdigest()[:24] + self.provisioning = RuntimeProvisioningStore(config) + self._containers: dict[UUID, ConversationContainer] = {} + self._starts: dict[UUID, asyncio.Task[ConversationContainer]] = {} + self._lock = asyncio.Lock() + + def configure_service(self, service: ConversationService) -> None: + service.sync_external_catalog = True + service.runtime_cipher_resolver = ( + lambda conversation_id: self.provisioning.load(conversation_id).cipher + ) + + async def start(self) -> None: + await asyncio.to_thread(self.cleanup_stale_containers) + + def add_execution_routes(self, router: APIRouter) -> None: + from openhands.agent_server.docker_runtime.routers import ( + docker_conversation_router, + ) + + router.include_router(docker_conversation_router) + + @property + def workspace_router(self) -> APIRouter: + from openhands.agent_server.docker_runtime.routers import ( + docker_workspace_router, + ) + + return docker_workspace_router + + @property + def conversation_sockets_router(self) -> APIRouter: + from openhands.agent_server.docker_runtime.routers import docker_sockets_router + + return docker_sockets_router + + @property + def session_sockets_router(self) -> APIRouter: + from openhands.agent_server.docker_runtime.routers import ( + docker_session_sockets_router, + ) + + return docker_session_sockets_router + + def conversation_dir(self, conversation_id: UUID) -> Path: + return self.provisioning.direct_child( + self.config.conversations_path, conversation_id.hex + ) + + def workspace_dir(self, conversation_id: UUID) -> Path: + workspace = self.provisioning.load(conversation_id).workspace_path + if workspace.is_symlink(): + raise ValueError("Conversation workspace must not be a symlink") + return workspace.resolve() + + def get(self, conversation_id: UUID) -> ConversationContainer | None: + return self._containers.get(conversation_id) + + def is_starting(self, conversation_id: UUID) -> bool: + return conversation_id in self._starts + + def cleanup_stale_containers(self) -> None: + result = execute_command( + ["docker", "ps", "-aq", "--filter", f"label={_OWNER_LABEL}={self.owner}"] + ) + if result.returncode != 0: + logger.warning("Failed to list stale conversation containers") + return + ids = result.stdout.split() + if ids: + execute_command(["docker", "rm", "-f", *ids]) + + async def get_or_create(self, conversation_id: UUID) -> ConversationContainer: + async with self._lock: + container = self._containers.get(conversation_id) + + if container is not None: + if await asyncio.to_thread(container.is_running): + return container + async with self._lock: + if self._containers.get(conversation_id) is container: + self._containers.pop(conversation_id) + + async with self._lock: + task = self._starts.get(conversation_id) + if task is None: + task = asyncio.create_task( + asyncio.to_thread(self._build_container, conversation_id) + ) + self._starts[conversation_id] = task + + try: + container = await asyncio.shield(task) + except BaseException: + async with self._lock: + if self._starts.get(conversation_id) is task: + self._starts.pop(conversation_id, None) + raise + + async with self._lock: + existing = self._containers.get(conversation_id) + if existing is not None: + if existing is not container: + await asyncio.to_thread(container.stop) + return existing + if self._starts.get(conversation_id) is not task: + await asyncio.to_thread(container.stop) + raise RuntimeError("Conversation container start was cancelled") + self._starts.pop(conversation_id, None) + self._containers[conversation_id] = container + return container + + async def stop(self, conversation_id: UUID) -> None: + async with self._lock: + task = self._starts.pop(conversation_id, None) + container = self._containers.pop(conversation_id, None) + if task is not None: + try: + started = await task + except Exception: + started = None + container = container or started + if container is not None: + await asyncio.to_thread(container.stop) + + async def shutdown(self) -> None: + ids = set(self._containers) | set(self._starts) + await asyncio.gather(*(self.stop(cid) for cid in ids), return_exceptions=True) + + def _build_container(self, conversation_id: UUID) -> ConversationContainer: + identity = self.provisioning.load(conversation_id) + runtime_dir = self.provisioning.runtime_dir(conversation_id) + persistence_dir = self.provisioning.direct_child(runtime_dir, "persistence") + conversation_dir = self.conversation_dir(conversation_id) + workspace_dir = self.workspace_dir(conversation_id) + for directory in (persistence_dir, conversation_dir, workspace_dir): + directory.mkdir(parents=True, mode=0o700, exist_ok=True) + + env = sanitized_env() + env.update( + { + "HOME": _PERSISTENCE_DIR, + "OH_CONVERSATIONS_PATH": _CONVERSATIONS_DIR, + "OH_PERSISTENCE_DIR": _PERSISTENCE_DIR, + "OH_CONVERSATION_RUNTIME": "local", + "OH_SECRET_KEY": identity.encryption_key.get_secret_value(), + V1_SESSION_API_KEY_ENV: identity.api_key.get_secret_value(), + "OH_RUNTIME_LAUNCHED_PROFILE": ( + identity.launched_agent_profile.model_dump_json() + if identity.launched_agent_profile + else "" + ), + } + ) + if "DEBUG" in os.environ: + env["DEBUG"] = os.environ["DEBUG"] + + flags: list[str] = [] + for name in ( + "HOME", + "OH_CONVERSATIONS_PATH", + "OH_PERSISTENCE_DIR", + "OH_CONVERSATION_RUNTIME", + "OH_SECRET_KEY", + V1_SESSION_API_KEY_ENV, + "OH_RUNTIME_LAUNCHED_PROFILE", + "DEBUG", + ): + if name in env: + flags.extend(("-e", name)) + for host, target in ( + (conversation_dir, f"{_CONVERSATIONS_DIR}/{conversation_id.hex}"), + (persistence_dir, _PERSISTENCE_DIR), + (workspace_dir, _WORKSPACE_DIR), + ): + flags.extend(("-v", f"{host}:{target}")) + if self.config.conversation_container_memory: + flags.extend(("--memory", self.config.conversation_container_memory)) + if self.config.conversation_container_cpus is not None: + flags.extend(("--cpus", str(self.config.conversation_container_cpus))) + if self.config.conversation_container_pids_limit is not None: + flags.extend( + ("--pids-limit", str(self.config.conversation_container_pids_limit)) + ) + + command = [ + "docker", + "run", + "-d", + "--rm", + "--user", + f"{os.getuid()}:{os.getgid()}", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--label", + f"{_OWNER_LABEL}={self.owner}", + "--name", + f"agent-server-conversation-{uuid4()}", + "-p", + "127.0.0.1::8000", + *flags, + self.config.conversation_image, + "--host", + "0.0.0.0", + "--port", + "8000", + ] + result = subprocess.run( + command, env=env, capture_output=True, text=True, check=False + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "docker run failed") + + container_id = result.stdout.strip() + try: + binding = execute_command(["docker", "port", container_id, "8000/tcp"]) + address, port = binding.stdout.strip().rsplit(":", 1) + if binding.returncode != 0 or address != "127.0.0.1": + raise RuntimeError("Docker did not create a loopback port binding") + container = ConversationContainer( + host=f"http://127.0.0.1:{int(port)}", + api_key=identity.api_key.get_secret_value(), + container_id=container_id, + ) + self._wait_until_ready(container) + return container + except BaseException: + execute_command(["docker", "stop", container_id]) + raise + + def _wait_until_ready(self, container: ConversationContainer) -> None: + deadline = time.monotonic() + self.config.conversation_container_startup_timeout + while time.monotonic() < deadline: + try: + with urlopen(container.host + "/health", timeout=1) as response: + if 200 <= response.status < 300: + return + except (URLError, TimeoutError, ConnectionError): + pass + running = execute_command( + [ + "docker", + "inspect", + "-f", + "{{.State.Running}}", + container.container_id, + ] + ) + if running.stdout.strip() != "true": + raise RuntimeError("Conversation container stopped during startup") + time.sleep(1) + raise RuntimeError("Conversation container failed to become healthy in time") diff --git a/openhands-agent-server/openhands/agent_server/docker_runtime/routers.py b/openhands-agent-server/openhands/agent_server/docker_runtime/routers.py new file mode 100644 index 0000000000..9c03f52aeb --- /dev/null +++ b/openhands-agent-server/openhands/agent_server/docker_runtime/routers.py @@ -0,0 +1,345 @@ +"""Conversation routes for the Docker runtime.""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Annotated +from uuid import UUID, uuid4 + +import httpx +from fastapi import APIRouter, HTTPException, Query, Request, WebSocket +from starlette.responses import JSONResponse, Response, StreamingResponse + +from openhands.agent_server.docker_runtime.mediation import ( + materialize_secrets, + prepare_start, + serialize_start, +) +from openhands.agent_server.docker_runtime.proxy import ( + bridge_websocket, + proxy_http, + strip_auth_query, +) +from openhands.agent_server.docker_runtime.registry import ( + ConversationContainer, + DockerConversationRegistry, +) +from openhands.agent_server.models import ( + ConversationRuntimeInfo, + ConversationRuntimeStatus, + UpdateSecretsRequest, +) +from openhands.agent_server.utils import safe_rmtree +from openhands.sdk.logger import get_logger +from openhands.sdk.profiles.resolver import DanglingMcpServerRef, ProfileNotFound + + +logger = get_logger(__name__) + + +def get_registry(request: Request) -> DockerConversationRegistry: + registry = request.app.state.conversation_registry + if not isinstance(registry, DockerConversationRegistry): + raise HTTPException(503, "Docker conversation runtime is unavailable") + return registry + + +async def _container( + registry: DockerConversationRegistry, conversation_id: UUID +) -> ConversationContainer: + if not registry.provisioning.manifest_path(conversation_id).is_file(): + raise HTTPException(404, "Conversation not found") + try: + return await registry.get_or_create(conversation_id) + except Exception as exc: + logger.exception("Could not start conversation container %s", conversation_id) + raise HTTPException(502, "Could not start conversation container") from exc + + +def _upstream_path(request: Request, path: str) -> str: + query = strip_auth_query("?" + request.url.query).lstrip("?") + return f"{path}?{query}" if query else path + + +docker_conversation_router = APIRouter( + prefix="/conversations", tags=["Docker Conversations"] +) + + +@docker_conversation_router.post("") +async def start_conversation( + request: Request, + include_skills: Annotated[bool, Query()] = False, +) -> JSONResponse: + try: + body = json.loads(await request.body()) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise HTTPException(400, "Invalid JSON body") from exc + if not isinstance(body, dict): + raise HTTPException(422, "Expected a JSON object") + + workspace = body.get("workspace") + if workspace is not None and ( + not isinstance(workspace, dict) + or workspace.get("kind", "LocalWorkspace") != "LocalWorkspace" + ): + raise HTTPException(422, "Docker conversations require a local workspace") + working_dir = (workspace or {}).get("working_dir", "/workspace") + host_workspace = None if working_dir == "/workspace" else Path(working_dir) + + try: + conversation_id = ( + UUID(body["conversation_id"]) if body.get("conversation_id") else uuid4() + ) + except (TypeError, ValueError) as exc: + raise HTTPException(400, "Invalid conversation_id") from exc + body["conversation_id"] = str(conversation_id) + body["workspace"] = {"kind": "LocalWorkspace", "working_dir": "/workspace"} + + registry = get_registry(request) + try: + prepared, launched = await prepare_start(body, registry.config) + identity = registry.provisioning.create(conversation_id, host_workspace) + if launched is not None and identity.launched_agent_profile is None: + identity = identity.model_copy(update={"launched_agent_profile": launched}) + registry.provisioning.save(identity) + container = await registry.get_or_create(conversation_id) + payload = serialize_start(prepared, identity) + async with httpx.AsyncClient(timeout=60) as client: + response = await client.post( + f"{container.host}/api/conversations", + params={"include_skills": include_skills}, + headers={"X-Session-API-Key": container.api_key}, + json=payload, + ) + except ProfileNotFound as exc: + raise HTTPException(404, str(exc)) from exc + except DanglingMcpServerRef as exc: + raise HTTPException( + 422, + {"message": str(exc), "dangling_mcp_server_refs": exc.missing}, + ) from exc + except ValueError as exc: + raise HTTPException(422, str(exc)) from exc + except httpx.HTTPError as exc: + await registry.stop(conversation_id) + raise HTTPException( + 502, "Conversation container did not accept the request" + ) from exc + except Exception as exc: + await registry.stop(conversation_id) + logger.exception("Could not create conversation container") + raise HTTPException(502, "Could not create conversation container") from exc + + content = response.json() if response.content else None + if response.is_error: + await registry.stop(conversation_id) + content = {"detail": "Conversation runtime rejected the request"} + return JSONResponse(content=content, status_code=response.status_code) + + +@docker_conversation_router.get( + "/{conversation_id}/runtime", response_model=ConversationRuntimeInfo +) +async def runtime_info( + conversation_id: UUID, request: Request +) -> ConversationRuntimeInfo: + registry = get_registry(request) + if not registry.provisioning.manifest_path(conversation_id).is_file(): + raise HTTPException(404, "Conversation not found") + return ConversationRuntimeInfo( + runtime_status=( + ConversationRuntimeStatus.AVAILABLE + if registry.get(conversation_id) + else ConversationRuntimeStatus.STARTING + if registry.is_starting(conversation_id) + else ConversationRuntimeStatus.MISSING + ), + can_resume=True, + ) + + +@docker_conversation_router.post("/{conversation_id}/runtime/credentials") +async def runtime_credentials(conversation_id: UUID, request: Request) -> JSONResponse: + """Return the key for an authenticated client attached to this runtime.""" + registry = get_registry(request) + if not registry.conversation_dir(conversation_id).joinpath("meta.json").is_file(): + raise HTTPException(404, "Conversation not found") + identity = registry.provisioning.load(conversation_id) + return JSONResponse({"session_api_key": identity.api_key.get_secret_value()}) + + +@docker_conversation_router.delete("/{conversation_id}/runtime", status_code=204) +async def release_runtime(conversation_id: UUID, request: Request) -> Response: + """Stop the container while retaining conversation and workspace state.""" + registry = get_registry(request) + if not registry.conversation_dir(conversation_id).joinpath("meta.json").is_file(): + raise HTTPException(404, "Conversation not found") + try: + await registry.stop(conversation_id) + except Exception as exc: + logger.exception("Could not release conversation runtime %s", conversation_id) + raise HTTPException(502, "Could not release conversation runtime") from exc + return Response(status_code=204) + + +@docker_conversation_router.post( + "/{conversation_id}/runtime/reprovision", response_model=ConversationRuntimeInfo +) +async def reprovision_runtime( + conversation_id: UUID, request: Request +) -> ConversationRuntimeInfo: + registry = get_registry(request) + await _container(registry, conversation_id) + return ConversationRuntimeInfo( + runtime_status=ConversationRuntimeStatus.AVAILABLE, can_resume=True + ) + + +@docker_conversation_router.delete("/{conversation_id}") +async def delete_conversation(conversation_id: UUID, request: Request) -> Response: + registry = get_registry(request) + if not registry.provisioning.manifest_path(conversation_id).is_file(): + raise HTTPException(404, "Conversation not found") + + # Stopping the container closes its conversation service. The outer server + # owns the bind-mounted state and removes it after Docker has unmounted it; + # asking the inner server to remove the mount root leaves that root in a + # partially deleted state. + await registry.stop(conversation_id) + registry.provisioning.manifest_path(conversation_id).unlink(missing_ok=True) + await asyncio.to_thread( + safe_rmtree, registry.provisioning.runtime_dir(conversation_id) + ) + await asyncio.to_thread(safe_rmtree, registry.conversation_dir(conversation_id)) + return Response(status_code=200) + + +@docker_conversation_router.api_route( + "/{conversation_id}", + methods=["GET", "POST", "PUT", "PATCH", "OPTIONS", "HEAD"], +) +async def proxy_conversation_root( + conversation_id: UUID, request: Request +) -> StreamingResponse: + return await proxy_conversation(conversation_id, "", request) + + +@docker_conversation_router.api_route( + "/{conversation_id}/{tail:path}", + methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"], +) +async def proxy_conversation( + conversation_id: UUID, tail: str, request: Request +) -> StreamingResponse: + if tail.split("/", 1)[0] in { + "credential-bindings", + "fork", + "switch_llm", + "switch_profile", + }: + raise HTTPException(501, "This operation is unavailable in Docker runtime mode") + registry = get_registry(request) + container = await _container(registry, conversation_id) + if tail == "secrets" and request.method == "POST": + try: + update = UpdateSecretsRequest.model_validate_json(await request.body()) + except ValueError as exc: + raise HTTPException(422, "Invalid secrets payload") from exc + profile = registry.provisioning.load(conversation_id).launched_agent_profile + secrets = update.secrets + if profile is not None: + secrets = { + name: source + for name, source in secrets.items() + if profile.allows_secret(name) + } + materialized = await asyncio.to_thread(materialize_secrets, secrets) + body = UpdateSecretsRequest(secrets=materialized).model_dump( + mode="json", context={"expose_secrets": "plaintext"} + ) + return await proxy_http( + request, + container, + upstream_path=_upstream_path( + request, f"/api/conversations/{conversation_id}/{tail}" + ), + body=json.dumps(body).encode(), + ) + return await proxy_http( + request, + container, + upstream_path=_upstream_path( + request, f"/api/conversations/{conversation_id}/{tail}" + ), + ) + + +docker_workspace_router = APIRouter(prefix="/conversations", tags=["Docker Workspace"]) + + +@docker_workspace_router.get("/{conversation_id}/workspace/{file_path:path}") +async def proxy_workspace_file( + conversation_id: UUID, file_path: str, request: Request +) -> StreamingResponse: + registry = get_registry(request) + container = await _container(registry, conversation_id) + return await proxy_http( + request, + container, + upstream_path=_upstream_path( + request, f"/api/conversations/{conversation_id}/workspace/{file_path}" + ), + ) + + +docker_sockets_router = APIRouter(prefix="/sockets", tags=["Docker WebSockets"]) +docker_session_sockets_router = APIRouter(prefix="/sockets", tags=["Docker WebSockets"]) + + +async def _proxy_socket( + websocket: WebSocket, + conversation_id: UUID, + session_api_key: str | None, + socket_name: str, +) -> None: + from openhands.agent_server.sockets import _accept_authenticated_websocket + + if not await _accept_authenticated_websocket(websocket, session_api_key): + return + registry = websocket.app.state.conversation_registry + if not isinstance(registry, DockerConversationRegistry): + await websocket.close(code=1011) + return + try: + container = await _container(registry, conversation_id) + except HTTPException as exc: + await websocket.close(code=1008 if exc.status_code == 404 else 1011) + return + query = strip_auth_query("?" + websocket.url.query).lstrip("?") + path = f"/sockets/{socket_name}/{conversation_id}" + await bridge_websocket( + websocket, + container, + upstream_path=f"{path}?{query}" if query else path, + ) + + +@docker_sockets_router.websocket("/events/{conversation_id}") +async def proxy_events( + websocket: WebSocket, + conversation_id: UUID, + session_api_key: Annotated[str | None, Query(alias="session_api_key")] = None, +) -> None: + await _proxy_socket(websocket, conversation_id, session_api_key, "events") + + +@docker_session_sockets_router.websocket("/session/{conversation_id}") +async def proxy_session( + websocket: WebSocket, + conversation_id: UUID, + session_api_key: Annotated[str | None, Query(alias="session_api_key")] = None, +) -> None: + await _proxy_socket(websocket, conversation_id, session_api_key, "session") diff --git a/openhands-agent-server/openhands/agent_server/init_router.py b/openhands-agent-server/openhands/agent_server/init_router.py index 57d8ce4d95..8bdc002c71 100644 --- a/openhands-agent-server/openhands/agent_server/init_router.py +++ b/openhands-agent-server/openhands/agent_server/init_router.py @@ -23,6 +23,10 @@ from openhands.agent_server.bash_service import BashEventService from openhands.agent_server.config import Config, TelemetrySpec, WebhookSpec +from openhands.agent_server.conversation_registry import ( + ConversationRegistry, + create_conversation_registry, +) from openhands.agent_server.conversation_service import ConversationService from openhands.agent_server.server_details_router import mark_initialization_complete from openhands.agent_server.telemetry import ( @@ -201,6 +205,7 @@ def __init__(self, app: FastAPI, base_config: Config) -> None: self._error: str | None = None self._lock = asyncio.Lock() self._entered_service: ConversationService | None = None + self._entered_conversation_registry: ConversationRegistry | None = None self._entered_bash_service: BashEventService | None = None @property @@ -244,6 +249,8 @@ async def initialize(self, req: InitRequest) -> InitStatus: service = ConversationService.get_instance(new_config) cs_mod._conversation_service = service + conversation_registry = create_conversation_registry(new_config) + conversation_registry.configure_service(service) bash_svc = BashEventService(bash_events_dir=new_config.bash_events_dir) await bash_svc.__aenter__() @@ -251,8 +258,11 @@ async def initialize(self, req: InitRequest) -> InitStatus: await service.__aenter__() self._entered_service = service + await conversation_registry.start() + self._entered_conversation_registry = conversation_registry self._app.state.config = new_config self._app.state.conversation_service = service + self._app.state.conversation_registry = conversation_registry self._app.state.bash_event_service = bash_svc # Re-derive root_path from the merged config so Doc URLS are valid @@ -284,6 +294,9 @@ async def teardown(self) -> None: that were never initialized don't need any cleanup. """ if self._entered_service is not None: + if self._entered_conversation_registry is not None: + await self._entered_conversation_registry.shutdown() + self._entered_conversation_registry = None await self._entered_service.__aexit__(None, None, None) self._entered_service = None if self._entered_bash_service is not None: diff --git a/openhands-agent-server/openhands/agent_server/server_details_router.py b/openhands-agent-server/openhands/agent_server/server_details_router.py index 7ab2de5cfa..f4ee7e8591 100644 --- a/openhands-agent-server/openhands/agent_server/server_details_router.py +++ b/openhands-agent-server/openhands/agent_server/server_details_router.py @@ -5,7 +5,7 @@ from importlib.metadata import version from typing import Literal -from fastapi import APIRouter, Response +from fastapi import APIRouter, Request, Response from pydantic import BaseModel, Field from openhands.sdk.tool.registry import list_usable_tools @@ -118,10 +118,17 @@ async def ready(response: Response) -> dict[str, str]: return {"status": "initializing", "message": "Server is still initializing"} -@server_details_router.get("/server_info") -async def get_server_info() -> ServerInfo: +def build_server_info( + conversation_runtime: Literal["local", "docker"] = "local", +) -> ServerInfo: now = time.time() return ServerInfo( uptime=int(now - _start_time), idle_time=int(now - _last_event_time), + conversation_runtime=conversation_runtime, ) + + +@server_details_router.get("/server_info") +async def get_server_info(request: Request) -> ServerInfo: + return build_server_info(request.app.state.config.conversation_runtime) diff --git a/openhands-agent-server/openhands/agent_server/sockets.py b/openhands-agent-server/openhands/agent_server/sockets.py index f009c3dcef..1cc9c0f52e 100644 --- a/openhands-agent-server/openhands/agent_server/sockets.py +++ b/openhands-agent-server/openhands/agent_server/sockets.py @@ -51,7 +51,8 @@ from openhands.sdk.utils.paging import page_iterator -sockets_router = APIRouter(prefix="/sockets", tags=["WebSockets"]) +conversation_sockets_router = APIRouter(prefix="/sockets", tags=["WebSockets"]) +bash_sockets_router = APIRouter(prefix="/sockets", tags=["WebSockets"]) conversation_service = get_default_conversation_service() bash_event_service = get_default_bash_event_service() logger = logging.getLogger(__name__) @@ -223,7 +224,7 @@ async def _accept_authenticated_websocket( return True -@sockets_router.websocket("/events/{conversation_id}") +@conversation_sockets_router.websocket("/events/{conversation_id}") async def events_socket( conversation_id: UUID, websocket: WebSocket, @@ -383,7 +384,7 @@ async def events_socket( await event_service.unsubscribe_from_events(subscriber_id) -@sockets_router.websocket("/bash-events") +@bash_sockets_router.websocket("/bash-events") async def bash_events_socket( websocket: WebSocket, session_api_key: Annotated[str | None, Query(alias="session_api_key")] = None, diff --git a/tests/agent_server/docker_runtime/__init__.py b/tests/agent_server/docker_runtime/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/agent_server/docker_runtime/test_mediation.py b/tests/agent_server/docker_runtime/test_mediation.py new file mode 100644 index 0000000000..614329a013 --- /dev/null +++ b/tests/agent_server/docker_runtime/test_mediation.py @@ -0,0 +1,136 @@ +from uuid import uuid4 + +import pytest +from pydantic import SecretStr + +from openhands.agent_server.config import Config +from openhands.agent_server.docker_runtime.mediation import ( + prepare_start, + serialize_start, +) +from openhands.agent_server.docker_runtime.provisioning import RuntimeProvisioningStore +from openhands.agent_server.persistence import ( + get_agent_profile_store, + get_llm_profile_store, +) +from openhands.sdk import LLM, Agent +from openhands.sdk.context import AgentContext +from openhands.sdk.conversation.request import StartConversationRequest +from openhands.sdk.profiles import OpenHandsAgentProfile +from openhands.sdk.secret import LookupSecret, StaticSecret +from openhands.sdk.workspace import LocalWorkspace + + +def config(tmp_path, monkeypatch) -> Config: + monkeypatch.setenv("OH_PERSISTENCE_DIR", str(tmp_path / "persistence")) + monkeypatch.setenv("OH_INTERNAL_SERVER_URL", "http://127.0.0.1:8123") + return Config( + conversations_path=tmp_path / "conversations", + workspace_path=tmp_path / "workspaces", + secret_key=SecretStr("outer-key"), + session_api_keys=["outer-session"], + ) + + +@pytest.mark.asyncio +async def test_materializes_request_secret_sources(tmp_path, monkeypatch): + runtime_config = config(tmp_path, monkeypatch) + looked_up = [] + + def get_value(secret): + looked_up.append(secret.url) + return "selected-value" + + monkeypatch.setattr(LookupSecret, "get_value", get_value) + request = StartConversationRequest( + workspace=LocalWorkspace(working_dir="/workspace"), + agent=Agent(llm=LLM(model="test", api_key=SecretStr("model-key"))), + secrets={ + "SELECTED": LookupSecret( + url="/api/settings/secrets/SELECTED", + headers={"X-Session-API-Key": "outer-session"}, + ) + }, + ) + + prepared, launched = await prepare_start( + request.model_dump(mode="json"), runtime_config + ) + assert launched is None + assert looked_up == ["http://127.0.0.1:8123/api/settings/secrets/SELECTED"] + assert isinstance(prepared.secrets["SELECTED"], StaticSecret) + + identity = RuntimeProvisioningStore(runtime_config).create(uuid4()) + payload = serialize_start(prepared, identity) + assert "selected-value" not in str(payload) + assert "outer-session" not in str(payload) + received = StartConversationRequest.model_validate( + payload, context={"cipher": identity.cipher} + ) + assert received.secrets["SELECTED"].get_value() == "selected-value" + + +@pytest.mark.asyncio +async def test_materializes_agent_context_secret_sources(tmp_path, monkeypatch): + runtime_config = config(tmp_path, monkeypatch) + monkeypatch.setattr(LookupSecret, "get_value", lambda secret: "context-value") + request = StartConversationRequest( + workspace=LocalWorkspace(working_dir="/workspace"), + agent=Agent( + llm=LLM(model="test"), + agent_context=AgentContext( + secrets={"CONTEXT_SECRET": LookupSecret(url="/secret")} + ), + ), + ) + prepared, _ = await prepare_start(request.model_dump(mode="json"), runtime_config) + context = prepared.agent.agent_context + assert context is not None + assert context.secrets is not None + source = context.secrets["CONTEXT_SECRET"] + assert isinstance(source, StaticSecret) + assert source.get_value() == "context-value" + + +@pytest.mark.asyncio +async def test_profile_uses_existing_resolver_and_secret_allowlist( + tmp_path, monkeypatch +): + runtime_config = config(tmp_path, monkeypatch) + get_llm_profile_store().save( + "docker-test-model", + LLM(model="test", api_key=SecretStr("model-key")), + include_secrets=True, + cipher=runtime_config.cipher, + ) + profile = OpenHandsAgentProfile( + name="docker-test-profile", + llm_profile_ref="docker-test-model", + tools=[], + mcp_server_refs=[], + secret_refs=["ALLOWED"], + ) + get_agent_profile_store().save(profile) + monkeypatch.setattr( + "openhands.agent_server.conversation_service.discover_profile_skills", + lambda: [], + ) + monkeypatch.setattr( + LookupSecret, "get_value", lambda secret: secret.url.rsplit("/", 1)[-1] + ) + request = StartConversationRequest( + workspace=LocalWorkspace(working_dir="/workspace"), + agent_profile_id=profile.id, + secrets={ + name: LookupSecret(url=f"/api/settings/secrets/{name}") + for name in ("ALLOWED", "UNRELATED") + }, + ) + + prepared, launched = await prepare_start( + request.model_dump(mode="json"), runtime_config + ) + assert set(prepared.secrets) == {"ALLOWED"} + assert prepared.agent_profile_id is None + assert launched is not None + assert launched.agent_profile_id == profile.id diff --git a/tests/agent_server/docker_runtime/test_provisioning.py b/tests/agent_server/docker_runtime/test_provisioning.py new file mode 100644 index 0000000000..cd12f5225c --- /dev/null +++ b/tests/agent_server/docker_runtime/test_provisioning.py @@ -0,0 +1,92 @@ +from uuid import uuid4 + +import pytest +from pydantic import SecretStr + +from openhands.agent_server.config import Config +from openhands.agent_server.docker_runtime.provisioning import RuntimeProvisioningStore + + +def config(tmp_path, monkeypatch) -> Config: + monkeypatch.setenv("OH_PERSISTENCE_DIR", str(tmp_path / "persistence")) + return Config( + conversations_path=tmp_path / "conversations", + workspace_path=tmp_path / "workspaces", + secret_key=SecretStr("outer-key"), + session_api_keys=["outer-session"], + ) + + +def test_each_runtime_gets_encrypted_independent_credentials(tmp_path, monkeypatch): + runtime_config = config(tmp_path, monkeypatch) + store = RuntimeProvisioningStore(runtime_config) + first = store.create(uuid4()) + second = store.create(uuid4()) + + assert first.api_key != second.api_key + assert first.encryption_key != second.encryption_key + assert first.api_key.get_secret_value() != "outer-session" + assert RuntimeProvisioningStore(runtime_config).load(first.conversation_id) == first + manifest = store.manifest_path(first.conversation_id) + serialized = manifest.read_text() + assert first.api_key.get_secret_value() not in serialized + assert first.encryption_key.get_secret_value() not in serialized + assert manifest.parent.stat().st_mode & 0o777 == 0o700 + + +def test_runtime_identity_cache_refreshes_after_manifest_update(tmp_path, monkeypatch): + runtime_config = config(tmp_path, monkeypatch) + writer = RuntimeProvisioningStore(runtime_config) + identity = writer.create(uuid4()) + reader = RuntimeProvisioningStore(runtime_config) + + cached = reader.load(identity.conversation_id) + assert reader.load(identity.conversation_id) is cached + + updated = identity.model_copy(update={"api_key": SecretStr("rotated-key")}) + writer.save(updated) + refreshed = reader.load(identity.conversation_id) + + assert refreshed is not cached + assert refreshed.api_key.get_secret_value() == "rotated-key" + + +def test_existing_local_conversation_is_not_reinterpreted(tmp_path, monkeypatch): + runtime_config = config(tmp_path, monkeypatch) + store = RuntimeProvisioningStore(runtime_config) + conversation_id = uuid4() + directory = runtime_config.conversations_path / conversation_id.hex + directory.mkdir(parents=True) + (directory / "base_state.json").write_text("local state") + + with pytest.raises(ValueError, match="existing local conversation"): + store.create(conversation_id) + + +def test_runtime_keeps_the_selected_workspace(tmp_path, monkeypatch): + store = RuntimeProvisioningStore(config(tmp_path, monkeypatch)) + conversation_id = uuid4() + workspace = tmp_path / "automation-run" + other = tmp_path / "other-run" + workspace.mkdir() + other.mkdir() + + assert store.create(conversation_id, workspace).workspace_path == workspace + assert store.create(conversation_id, workspace).workspace_path == workspace + with pytest.raises(ValueError, match="cannot change workspaces"): + store.create(conversation_id, other) + + +def test_runtime_mount_rejects_symlink(tmp_path, monkeypatch): + runtime_config = config(tmp_path, monkeypatch) + runtime_config.workspace_path.mkdir() + conversation_id = uuid4() + target = tmp_path / "outside" + target.mkdir() + (runtime_config.workspace_path / conversation_id.hex).symlink_to( + target, target_is_directory=True + ) + + store = RuntimeProvisioningStore(runtime_config) + with pytest.raises(ValueError, match="symlink"): + store.direct_child(runtime_config.workspace_path, conversation_id.hex) diff --git a/tests/agent_server/docker_runtime/test_proxy.py b/tests/agent_server/docker_runtime/test_proxy.py new file mode 100644 index 0000000000..2f122bb391 --- /dev/null +++ b/tests/agent_server/docker_runtime/test_proxy.py @@ -0,0 +1,134 @@ +import asyncio + +import pytest +from starlette.websockets import WebSocket + +from openhands.agent_server.docker_runtime.proxy import _bridge_websocket_loop + + +class Upstream: + def __init__(self, fail=False): + self.fail = fail + self.closed = False + self.stopped = asyncio.Event() + + def __aiter__(self): + return self + + async def __anext__(self): + try: + if self.fail: + raise RuntimeError("invalid upstream frame") + await asyncio.Future() + finally: + self.stopped.set() + + async def close(self): + self.closed = True + + +async def client_socket(): + incoming = asyncio.Queue() + await incoming.put({"type": "websocket.connect"}) + sent = [] + + async def send(message): + sent.append(message) + + client = WebSocket({"type": "websocket"}, incoming.get, send) + await client.accept() + return client, sent + + +@pytest.mark.asyncio +async def test_bridge_propagates_upstream_error_and_closes_connections(): + client, sent = await client_socket() + upstream = Upstream(fail=True) + with pytest.raises(RuntimeError, match="invalid upstream frame"): + await _bridge_websocket_loop(client, upstream) + assert upstream.closed + assert sent[-1]["type"] == "websocket.close" + + +@pytest.mark.asyncio +async def test_bridge_cancellation_closes_connections_and_child_tasks(): + client, sent = await client_socket() + upstream = Upstream() + task = asyncio.create_task(_bridge_websocket_loop(client, upstream)) + await asyncio.sleep(0.01) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert upstream.closed + assert upstream.stopped.is_set() + assert sent[-1]["type"] == "websocket.close" + + +def request_and_target(): + from starlette.requests import Request + + from openhands.agent_server.docker_runtime.registry import ( + ConversationContainer, + ) + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + return ( + Request({"type": "http", "method": "GET", "headers": []}, receive), + ConversationContainer("http://upstream", "inner-key", "test-container"), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "failure", ["ReadTimeout", "WriteError", "RemoteProtocolError", "cancel"] +) +async def test_http_setup_failure_closes_client_and_preserves_cancellation( + monkeypatch, failure +): + import httpx + from fastapi import HTTPException + + from openhands.agent_server.docker_runtime import proxy + + errors = { + "ReadTimeout": httpx.ReadTimeout, + "WriteError": httpx.WriteError, + "RemoteProtocolError": httpx.RemoteProtocolError, + "cancel": asyncio.CancelledError, + } + + def respond(request): + raise errors[failure]("upstream failed") + + client = httpx.AsyncClient(transport=httpx.MockTransport(respond)) + monkeypatch.setattr(proxy.httpx, "AsyncClient", lambda **kwargs: client) + expected = asyncio.CancelledError if failure == "cancel" else HTTPException + with pytest.raises(expected) as raised: + await proxy.proxy_http(*request_and_target(), upstream_path="/api/test") + if isinstance(raised.value, HTTPException): + assert raised.value.status_code == 502 + assert client.is_closed + + +@pytest.mark.asyncio +async def test_http_client_lives_until_the_stream_is_consumed(monkeypatch): + import httpx + + from openhands.agent_server.docker_runtime import proxy + + class Body(httpx.AsyncByteStream): + async def __aiter__(self): + yield b"result" + + client = httpx.AsyncClient( + transport=httpx.MockTransport( + lambda request: httpx.Response(200, stream=Body()) + ) + ) + monkeypatch.setattr(proxy.httpx, "AsyncClient", lambda **kwargs: client) + response = await proxy.proxy_http(*request_and_target(), upstream_path="/api/test") + assert not client.is_closed + assert [chunk async for chunk in response.body_iterator] == [b"result"] + assert client.is_closed diff --git a/tests/agent_server/docker_runtime/test_registry.py b/tests/agent_server/docker_runtime/test_registry.py new file mode 100644 index 0000000000..cd022a597e --- /dev/null +++ b/tests/agent_server/docker_runtime/test_registry.py @@ -0,0 +1,145 @@ +import asyncio +import subprocess +import threading +from uuid import UUID, uuid4 + +import pytest +from pydantic import SecretStr + +from openhands.agent_server.config import Config +from openhands.agent_server.docker_runtime.registry import ( + ConversationContainer, + DockerConversationRegistry, +) + + +def registry(tmp_path, monkeypatch) -> DockerConversationRegistry: + monkeypatch.setenv("OH_PERSISTENCE_DIR", str(tmp_path / "persistence")) + return DockerConversationRegistry( + Config( + conversations_path=tmp_path / "conversations", + workspace_path=tmp_path / "workspaces", + secret_key=SecretStr("outer-key"), + ) + ) + + +def container(conversation_id: UUID) -> ConversationContainer: + return ConversationContainer( + host=f"http://127.0.0.1/{conversation_id}", + api_key="inner-key", + container_id=f"container-{conversation_id}", + ) + + +def test_missing_container_is_already_stopped(monkeypatch): + missing = container(uuid4()) + monkeypatch.setattr( + "openhands.agent_server.docker_runtime.registry.execute_command", + lambda *_args, **_kwargs: subprocess.CompletedProcess( + [], 1, stdout="", stderr="No such container" + ), + ) + + missing.stop() + + +@pytest.mark.asyncio +async def test_same_conversation_shares_one_start(tmp_path, monkeypatch): + runtime = registry(tmp_path, monkeypatch) + conversation_id = uuid4() + calls = 0 + + def build(conversation_id: UUID): + nonlocal calls + calls += 1 + return container(conversation_id) + + runtime._build_container = build + first, second = await asyncio.gather( + runtime.get_or_create(conversation_id), + runtime.get_or_create(conversation_id), + ) + assert calls == 1 + assert first is second + + +@pytest.mark.asyncio +async def test_different_conversations_start_concurrently(tmp_path, monkeypatch): + runtime = registry(tmp_path, monkeypatch) + entered = set() + release = threading.Event() + + def build(conversation_id: UUID): + entered.add(conversation_id) + assert release.wait(5) + return container(conversation_id) + + runtime._build_container = build + ids = [uuid4(), uuid4()] + tasks = [asyncio.create_task(runtime.get_or_create(cid)) for cid in ids] + while len(entered) < 2: + await asyncio.sleep(0.01) + release.set() + await asyncio.gather(*tasks) + assert entered == set(ids) + + +@pytest.mark.asyncio +async def test_stale_cached_container_is_replaced(tmp_path, monkeypatch): + runtime = registry(tmp_path, monkeypatch) + conversation_id = uuid4() + stale = container(conversation_id) + fresh = ConversationContainer("http://fresh", "fresh-key", "fresh-container") + runtime._containers[conversation_id] = stale + monkeypatch.setattr(ConversationContainer, "is_running", lambda _self: False) + runtime._build_container = lambda conversation_id: fresh + + assert await runtime.get_or_create(conversation_id) is fresh + assert runtime.get(conversation_id) is fresh + + +def test_container_command_is_hardened_and_mounts_only_its_state(tmp_path, monkeypatch): + runtime = registry(tmp_path, monkeypatch) + conversation_id = uuid4() + runtime.provisioning.create(conversation_id) + commands = [] + + def run(command, **kwargs): + commands.append((command, kwargs["env"])) + return subprocess.CompletedProcess( + command, 0, stdout="container-id\n", stderr="" + ) + + def execute(command): + if command[:2] == ["docker", "port"]: + return subprocess.CompletedProcess( + command, 0, stdout="127.0.0.1:32123\n", stderr="" + ) + return subprocess.CompletedProcess(command, 0, stdout="true\n", stderr="") + + monkeypatch.setattr("subprocess.run", run) + monkeypatch.setattr( + "openhands.agent_server.docker_runtime.registry.execute_command", execute + ) + monkeypatch.setattr(runtime, "_wait_until_ready", lambda container: None) + + result = runtime._build_container(conversation_id) + command, env = commands[0] + assert result.host == "http://127.0.0.1:32123" + assert ["--cap-drop", "ALL"] == command[ + command.index("--cap-drop") : command.index("--cap-drop") + 2 + ] + assert "no-new-privileges" in command + assert "127.0.0.1::8000" in command + mounts = [command[index + 1] for index, item in enumerate(command) if item == "-v"] + assert len(mounts) == 3 + assert all( + conversation_id.hex in mount or mount.endswith(":/workspace") + for mount in mounts + ) + assert env["HOME"] == "/var/openhands/.openhands" + assert ["-e", "HOME"] == command[ + command.index("HOME") - 1 : command.index("HOME") + 1 + ] + assert env["OH_SECRET_KEY"] != "outer-key" diff --git a/tests/agent_server/docker_runtime/test_routes.py b/tests/agent_server/docker_runtime/test_routes.py new file mode 100644 index 0000000000..21ab589dfe --- /dev/null +++ b/tests/agent_server/docker_runtime/test_routes.py @@ -0,0 +1,222 @@ +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from pydantic import SecretStr +from starlette.requests import Request +from starlette.responses import Response +from starlette.routing import Match + +from openhands.agent_server.api import create_app +from openhands.agent_server.config import Config +from openhands.agent_server.docker_runtime.provisioning import RuntimeProvisioningStore +from openhands.agent_server.docker_runtime.registry import DockerConversationRegistry +from openhands.agent_server.docker_runtime.routers import ( + docker_conversation_router, + proxy_conversation, +) +from openhands.agent_server.models import UpdateSecretsRequest +from openhands.sdk.profiles.agent_profile import LaunchedAgentProfile +from openhands.sdk.secret import LookupSecret + + +def test_docker_mode_replaces_local_conversation_execution_routes(tmp_path): + app = create_app( + Config( + conversation_runtime="docker", + conversations_path=tmp_path / "conversations", + workspace_path=tmp_path / "workspaces", + secret_key=SecretStr("outer-key"), + ) + ) + paths = [getattr(route, "path", "") for route in app.routes] + assert "/api/conversations" in paths + assert "/sockets/events/{conversation_id}" in paths + assert "/sockets/session/{conversation_id}" in paths + assert "/sockets/bash-events" in paths + assert "/api/conversations/{conversation_id}/{tail:path}" in paths + assert "/api/conversations/{conversation_id}" in paths + assert "/api/host/bash/execute_bash_command" not in paths + assert "/api/bash/execute_bash_command" in paths + + scope = { + "type": "http", + "path": f"/api/conversations/{uuid4()}", + "root_path": "", + "method": "PATCH", + } + matched = [ + route + for route in app.routes + if hasattr(route, "matches") and route.matches(scope)[0] is Match.FULL + ] + assert getattr(matched[0], "endpoint").__name__ == "proxy_conversation_root" + + session_scope = { + "type": "websocket", + "path": f"/sockets/session/{uuid4()}", + "root_path": "", + } + matched = [ + route + for route in app.routes + if hasattr(route, "matches") and route.matches(session_scope)[0] is Match.FULL + ] + assert getattr(matched[0], "endpoint").__name__ == "proxy_session" + + # Static collection paths must reach their real handlers before the + # Docker ``/{conversation_id}`` catch-all tries to parse them as UUIDs. + client = TestClient(app) + for path in ("/api/conversations/search", "/api/conversations/count"): + assert client.get(path).status_code != 422 + + +def test_runtime_credentials_and_release_use_the_existing_sdk_contract( + tmp_path, monkeypatch +): + monkeypatch.setenv("OH_PERSISTENCE_DIR", str(tmp_path / "persistence")) + config = Config( + conversations_path=tmp_path / "conversations", + secret_key=SecretStr("outer-key"), + ) + conversation_id = uuid4() + identity = RuntimeProvisioningStore(config).create(conversation_id) + conversation_dir = config.conversations_path / conversation_id.hex + conversation_dir.mkdir(parents=True) + (conversation_dir / "meta.json").write_text("{}") + stopped = [] + + async def stop(conversation_id): + stopped.append(conversation_id) + + app = FastAPI() + registry = DockerConversationRegistry(config) + registry.stop = stop + app.state.conversation_registry = registry + app.include_router(docker_conversation_router, prefix="/api") + with TestClient(app) as client: + response = client.post( + f"/api/conversations/{conversation_id}/runtime/credentials" + ) + assert response.json() == { + "session_api_key": identity.api_key.get_secret_value() + } + assert ( + client.delete(f"/api/conversations/{conversation_id}/runtime").status_code + == 204 + ) + assert stopped == [conversation_id] + + +def test_delete_stops_runtime_before_removing_outer_owned_state(tmp_path, monkeypatch): + monkeypatch.setenv("OH_PERSISTENCE_DIR", str(tmp_path / "persistence")) + config = Config( + conversations_path=tmp_path / "conversations", + secret_key=SecretStr("outer-key"), + ) + conversation_id = uuid4() + registry = DockerConversationRegistry(config) + registry.provisioning.create(conversation_id) + conversation_dir = registry.conversation_dir(conversation_id) + conversation_dir.mkdir(parents=True) + (conversation_dir / "meta.json").write_text("{}") + runtime_dir = registry.provisioning.runtime_dir(conversation_id) + (runtime_dir / "persistence").mkdir() + registry.stop = AsyncMock() + + app = FastAPI() + app.state.conversation_registry = registry + app.include_router(docker_conversation_router, prefix="/api") + with TestClient(app) as client: + response = client.delete(f"/api/conversations/{conversation_id}") + + assert response.status_code == 200 + registry.stop.assert_awaited_once_with(conversation_id) + assert not conversation_dir.exists() + assert not runtime_dir.exists() + + +@pytest.mark.asyncio +async def test_secret_updates_are_materialized_and_profile_scoped( + tmp_path, monkeypatch +): + config = Config( + conversations_path=tmp_path / "conversations", + secret_key=SecretStr("outer-key"), + ) + registry = DockerConversationRegistry(config) + conversation_id = uuid4() + identity = registry.provisioning.create(conversation_id).model_copy( + update={ + "launched_agent_profile": LaunchedAgentProfile( + agent_profile_id=uuid4(), revision=1, secret_refs=["ALLOWED"] + ) + } + ) + registry.provisioning.save(identity) + looked_up = [] + + def get_value(secret): + looked_up.append(secret.url) + return f"resolved-{secret.url.rsplit('/', 1)[-1]}" + + monkeypatch.setattr(LookupSecret, "get_value", get_value) + captured = {} + + async def container(*_args): + return SimpleNamespace(host="http://inner", api_key="inner-key") + + async def proxy(*_args, **kwargs): + captured.update(kwargs) + return Response() + + monkeypatch.setattr( + "openhands.agent_server.docker_runtime.routers._container", container + ) + monkeypatch.setattr( + "openhands.agent_server.docker_runtime.routers.proxy_http", proxy + ) + payload = { + "secrets": { + name: LookupSecret( + url=f"http://outer/api/settings/secrets/{name}" + ).model_dump(mode="json", context={"expose_secrets": True}) + for name in ("ALLOWED", "DENIED") + } + } + sent = False + + async def receive(): + nonlocal sent + if sent: + return {"type": "http.request", "body": b"", "more_body": False} + sent = True + return { + "type": "http.request", + "body": json.dumps(payload).encode(), + "more_body": False, + } + + request = Request( + { + "type": "http", + "method": "POST", + "path": f"/api/conversations/{conversation_id}/secrets", + "query_string": b"", + "headers": [(b"content-type", b"application/json")], + "app": SimpleNamespace( + state=SimpleNamespace(conversation_registry=registry) + ), + }, + receive, + ) + await proxy_conversation(conversation_id, "secrets", request) + + forwarded = UpdateSecretsRequest.model_validate_json(captured["body"]) + assert set(forwarded.secrets) == {"ALLOWED"} + assert forwarded.secrets["ALLOWED"].get_value() == "resolved-ALLOWED" + assert looked_up == ["http://outer/api/settings/secrets/ALLOWED"] diff --git a/tests/agent_server/test_api.py b/tests/agent_server/test_api.py index 0629ee1b11..1da46651a3 100644 --- a/tests/agent_server/test_api.py +++ b/tests/agent_server/test_api.py @@ -388,6 +388,37 @@ async def test_services_handle_none_values(self): # Verify conversation service was set up assert mock_app.state.conversation_service == mock_conversation_service + async def test_registry_starts_before_conversation_recovery(self): + events = [] + registry = SimpleNamespace( + configure_service=lambda _service: events.append("configure"), + start=AsyncMock(side_effect=lambda: events.append("registry")), + shutdown=AsyncMock(), + ) + service = AsyncMock() + service.__aenter__.side_effect = lambda: events.append("service") or service + + with ( + patch( + "openhands.agent_server.api.get_default_conversation_service", + return_value=service, + ), + patch("openhands.agent_server.api.get_vscode_service", return_value=None), + patch( + "openhands.agent_server.api.get_tool_preload_service", + return_value=None, + ), + ): + mock_app = AsyncMock() + mock_app.state = SimpleNamespace( + config=Config(), conversation_registry=registry + ) + + async with api_lifespan(mock_app): + pass + + assert events[:3] == ["configure", "registry", "service"] + async def test_lifespan_defaults_and_restores_tmux_tmpdir( self, tmp_path, monkeypatch ): diff --git a/tests/agent_server/test_conversation_service.py b/tests/agent_server/test_conversation_service.py index bf1c29ef85..a5e1d2efb1 100644 --- a/tests/agent_server/test_conversation_service.py +++ b/tests/agent_server/test_conversation_service.py @@ -3633,6 +3633,40 @@ def test_non_acp_agent_unchanged(self): assert not hasattr(agent, "_on_activity") +@pytest.mark.asyncio +async def test_external_catalog_sync_discovers_conversation_added_after_startup( + tmp_path, sample_stored_conversation +): + conversations_dir = tmp_path / "conversations" + async with ConversationService( + conversations_dir=conversations_dir, sync_external_catalog=True + ) as service: + assert (await service.search_conversations()).items == [] + + conversation_dir = conversations_dir / sample_stored_conversation.id.hex + conversation_dir.mkdir(parents=True) + (conversation_dir / "meta.json").write_text( + sample_stored_conversation.model_dump_json() + ) + state = ConversationState( + id=sample_stored_conversation.id, + agent=_sample_agent(), + workspace=sample_stored_conversation.workspace, + persistence_dir=str(conversations_dir), + ) + (conversation_dir / "base_state.json").write_text(state.model_dump_json()) + + info = await service.get_conversation(sample_stored_conversation.id) + page = await service.search_conversations() + (conversation_dir / "meta.json").unlink() + removed = await service.get_conversation(sample_stored_conversation.id) + + assert info is not None + assert info.id == sample_stored_conversation.id + assert [item.id for item in page.items] == [sample_stored_conversation.id] + assert removed is None + + def _branch_events(conversation) -> list: """Log events excluding async ``ConversationStateUpdateEvent`` artifacts. @@ -4164,3 +4198,72 @@ def hold_state_lock(): holder.join(timeout=2) assert [item.id for item in page.items] == [conversation_info.id] + + +@pytest.mark.asyncio +async def test_external_catalog_refreshes_metadata_without_state_change( + persisted_conversation, +): + conversations_dir, conversation_id = persisted_conversation + directory = conversations_dir / conversation_id.hex + async with ConversationService( + conversations_dir=conversations_dir, sync_external_catalog=True + ) as service: + initial = await service.search_conversations() + assert initial.items[0].title is None + state_before = (directory / "base_state.json").read_bytes() + metadata = json.loads((directory / "meta.json").read_text()) + metadata["title"] = "Generated externally" + (directory / "meta.json").write_text(json.dumps(metadata)) + + info = await service.get_conversation(conversation_id) + assert info is not None + assert info.title == "Generated externally" + assert (await service.search_conversations()).items[0].title == info.title + assert (directory / "base_state.json").read_bytes() == state_before + + +@pytest.mark.asyncio +async def test_external_catalog_preserves_live_metadata(persisted_conversation): + conversations_dir, conversation_id = persisted_conversation + async with ConversationService( + conversations_dir=conversations_dir, sync_external_catalog=True + ) as service: + runtime = await service.get_event_service(conversation_id) + assert runtime is not None + runtime.stored = runtime.stored.model_copy(update={"title": "Live title"}) + metadata_path = conversations_dir / conversation_id.hex / "meta.json" + metadata = json.loads(metadata_path.read_text()) + metadata["title"] = "Stale disk title" + metadata_path.write_text(json.dumps(metadata)) + + info = await service.get_conversation(conversation_id) + assert info is not None + assert info.title == "Live title" + assert (await service.search_conversations()).items[0].title == "Live title" + assert await service.get_event_service(conversation_id) is runtime + + +@pytest.mark.asyncio +async def test_external_lookup_only_decrypts_requested_record(persisted_conversation): + conversations_dir, conversation_id = persisted_conversation + reads = [] + + def cipher_for(cid): + reads.append(cid) + return Cipher("catalog-test-key") + + async with ConversationService( + conversations_dir=conversations_dir, + sync_external_catalog=True, + runtime_cipher_resolver=cipher_for, + ) as service: + unrelated = uuid4() + directory = conversations_dir / unrelated.hex + directory.mkdir() + (directory / "meta.json").write_bytes( + (conversations_dir / conversation_id.hex / "meta.json").read_bytes() + ) + reads.clear() + assert await service.get_conversation(conversation_id) is not None + assert unrelated not in reads diff --git a/tests/agent_server/test_event_streaming.py b/tests/agent_server/test_event_streaming.py index 4a611efbdf..52a5b970d5 100644 --- a/tests/agent_server/test_event_streaming.py +++ b/tests/agent_server/test_event_streaming.py @@ -348,7 +348,7 @@ async def test_deltas_keep_idle_time_below_the_threshold( callback(_make_chunk(content="tok")) - assert (await server_details_router.get_server_info()).idle_time < _IDLE_THRESHOLD + assert server_details_router.build_server_info().idle_time < _IDLE_THRESHOLD @pytest.mark.asyncio diff --git a/tests/agent_server/test_server_details_router.py b/tests/agent_server/test_server_details_router.py index 4315230245..df2732eef1 100644 --- a/tests/agent_server/test_server_details_router.py +++ b/tests/agent_server/test_server_details_router.py @@ -4,6 +4,7 @@ import pytest from fastapi.testclient import TestClient +from pydantic import SecretStr import openhands.agent_server.server_details_router as sdr from openhands.agent_server.api import create_app @@ -87,6 +88,23 @@ def test_server_info_reports_credential_binding_probe(client): assert payload["conversation_runtime"] == "local" +def test_server_info_reports_configured_conversation_runtime(tmp_path, monkeypatch): + monkeypatch.setenv("OH_PERSISTENCE_DIR", str(tmp_path / "persistence")) + app = create_app( + Config( + static_files_path=None, + conversation_runtime="docker", + conversations_path=tmp_path / "conversations", + workspace_path=tmp_path / "workspaces", + secret_key=SecretStr("test-key"), + ) + ) + with TestClient(app) as docker_client: + assert ( + docker_client.get("/server_info").json()["conversation_runtime"] == "docker" + ) + + def test_server_info_reports_runtime_timeout_cap( client, monkeypatch: pytest.MonkeyPatch, From 733861a2b7a67d877b3580fd71be0455b6f1e8e7 Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Wed, 16 Sep 2026 22:40:59 +0200 Subject: [PATCH 3/6] chore(agent-server): remove deprecated desktop URL endpoint past its 1.49.0 deadline (#5105) --- .../deterministic-api.integration.test.ts | 8 ----- .../openhands/agent_server/api.py | 2 -- .../openhands/agent_server/desktop_router.py | 28 --------------- .../openhands/agent_server/runtime_router.py | 2 -- tests/agent_server/test_desktop_router.py | 35 ------------------- tests/agent_server/test_runtime_router.py | 1 - 6 files changed, 76 deletions(-) delete mode 100644 openhands-agent-server/openhands/agent_server/desktop_router.py delete mode 100644 tests/agent_server/test_desktop_router.py diff --git a/clients/typescript/src/__tests__/integration/deterministic-api.integration.test.ts b/clients/typescript/src/__tests__/integration/deterministic-api.integration.test.ts index 0b265f502b..b82dd4a541 100644 --- a/clients/typescript/src/__tests__/integration/deterministic-api.integration.test.ts +++ b/clients/typescript/src/__tests__/integration/deterministic-api.integration.test.ts @@ -85,14 +85,6 @@ describe('Deterministic API Integration Tests', () => { expect(Array.isArray(subAgents.agents)).toBe(true); expect(subAgents.agents.every((agent) => agent.is_builtin)).toBe(true); expect(typeof vscodeStatus.enabled).toBe('boolean'); - - try { - const desktopUrl = await manager.desktop.getUrl(); - expect(desktopUrl === null || typeof desktopUrl === 'string').toBe(true); - } catch (error) { - expect(error).toBeInstanceOf(HttpError); - expect((error as HttpError).status).toBe(503); - } }, config.testTimeout ); diff --git a/openhands-agent-server/openhands/agent_server/api.py b/openhands-agent-server/openhands/agent_server/api.py index aeb154385a..9c54fcd5b3 100644 --- a/openhands-agent-server/openhands/agent_server/api.py +++ b/openhands-agent-server/openhands/agent_server/api.py @@ -44,7 +44,6 @@ check_session_api_key, check_workspace_session, ) -from openhands.agent_server.desktop_router import desktop_router from openhands.agent_server.file_router import file_discovery_router, file_router from openhands.agent_server.git_router import git_router from openhands.agent_server.hooks_router import hooks_router @@ -436,7 +435,6 @@ def _add_api_routes(app: FastAPI) -> None: api_router.include_router(git_router) api_router.include_router(file_router) api_router.include_router(vscode_router) - api_router.include_router(desktop_router) api_router.include_router(skills_router) api_router.include_router(sub_agents_router) api_router.include_router(plugins_router) diff --git a/openhands-agent-server/openhands/agent_server/desktop_router.py b/openhands-agent-server/openhands/agent_server/desktop_router.py deleted file mode 100644 index c217e1088b..0000000000 --- a/openhands-agent-server/openhands/agent_server/desktop_router.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Desktop router for agent server API endpoints.""" - -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel - - -desktop_router = APIRouter(prefix="/desktop", tags=["Desktop"]) - - -class DesktopUrlResponse(BaseModel): - """Response model for Desktop URL.""" - - url: str | None - - -@desktop_router.get("/url", response_model=DesktopUrlResponse, deprecated=True) -async def get_desktop_url( - base_url: str = "http://localhost:8002", # noqa: ARG001 -) -> DesktopUrlResponse: - """Deprecated since v1.44.1 and scheduled for removal in v1.49.0. - - The VNC/desktop stack has been removed; this always returns 503, the - same response every deployment has always gotten since VNC defaults off. - """ - raise HTTPException( - status_code=503, - detail="Desktop is disabled. VNC/desktop support has been removed.", - ) diff --git a/openhands-agent-server/openhands/agent_server/runtime_router.py b/openhands-agent-server/openhands/agent_server/runtime_router.py index 7d2404cfc1..aa8b75137a 100644 --- a/openhands-agent-server/openhands/agent_server/runtime_router.py +++ b/openhands-agent-server/openhands/agent_server/runtime_router.py @@ -19,7 +19,6 @@ get_conversation_service, get_event_service, ) -from openhands.agent_server.desktop_router import desktop_router from openhands.agent_server.file_router import file_router from openhands.agent_server.git_router import git_router from openhands.agent_server.vscode_router import ( @@ -83,7 +82,6 @@ def create_runtime_router(route_class: type[APIRoute] = APIRoute) -> APIRouter: bash_router, file_router, git_router, - desktop_router, ): router.include_router(source) router.add_api_route("/vscode/url", get_runtime_vscode_url, methods=["GET"]) diff --git a/tests/agent_server/test_desktop_router.py b/tests/agent_server/test_desktop_router.py deleted file mode 100644 index 799bb83746..0000000000 --- a/tests/agent_server/test_desktop_router.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Tests for the deprecated desktop router stub.""" - -import pytest -from fastapi import HTTPException -from fastapi.testclient import TestClient - -from openhands.agent_server.api import create_app -from openhands.agent_server.config import Config -from openhands.agent_server.desktop_router import DesktopUrlResponse, get_desktop_url - - -@pytest.fixture -def client(): - config = Config(session_api_keys=[]) # Disable authentication for tests - app = create_app(config) - return TestClient(app) - - -@pytest.mark.asyncio -async def test_get_desktop_url_always_503(): - with pytest.raises(HTTPException) as exc_info: - await get_desktop_url() - - assert exc_info.value.status_code == 503 - - -def test_desktop_url_route_is_marked_deprecated(client): - schema = client.app.openapi() - operation = schema["paths"]["/api/desktop/url"]["get"] - assert operation["deprecated"] is True - - -def test_desktop_url_response_model(): - response = DesktopUrlResponse(url=None) - assert response.model_dump() == {"url": None} diff --git a/tests/agent_server/test_runtime_router.py b/tests/agent_server/test_runtime_router.py index 00bff12fec..c3c5d9e094 100644 --- a/tests/agent_server/test_runtime_router.py +++ b/tests/agent_server/test_runtime_router.py @@ -109,7 +109,6 @@ def test_canonical_runtime_openapi_preserves_methods_and_schemas(): ("bash", "post", "execute_bash_command"), ("file", "get", "download"), ("git", "get", "changes"), - ("desktop", "get", "url"), ("vscode", "get", "url"), ]: operation = paths[ From 3f5b3fa0db2335182d713d02e9cdc67b3c5f9176 Mon Sep 17 00:00:00 2001 From: Vasco Schiavo <115561717+VascoSch92@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:51:21 -0400 Subject: [PATCH 4/6] refactor(sdk): delegate plugin skills discovery to load_skills_from_dir (#5086) --- .../openhands/sdk/plugin/format/base.py | 37 +++++-------------- openhands-sdk/openhands/sdk/skills/skill.py | 27 +++++++++++--- openhands-sdk/openhands/sdk/skills/utils.py | 16 +++++++- tests/sdk/plugin/test_plugin_loading.py | 33 +++++++++++++++++ 4 files changed, 78 insertions(+), 35 deletions(-) diff --git a/openhands-sdk/openhands/sdk/plugin/format/base.py b/openhands-sdk/openhands/sdk/plugin/format/base.py index dc2b88047c..daee0401c7 100644 --- a/openhands-sdk/openhands/sdk/plugin/format/base.py +++ b/openhands-sdk/openhands/sdk/plugin/format/base.py @@ -18,7 +18,7 @@ from openhands.sdk.logger import get_logger from openhands.sdk.mcp.config import MCPServer from openhands.sdk.plugin.types import CommandDefinition, PluginManifest -from openhands.sdk.skills.skill import Skill +from openhands.sdk.skills.skill import Skill, load_skills_from_dir from openhands.sdk.skills.utils import find_skill_md from openhands.sdk.subagent.schema import AgentDefinition from openhands.sdk.utils.path import to_posix_path @@ -96,7 +96,14 @@ def load_skills(self, plugin_dir: Path) -> list[Skill]: """ skills_dir = plugin_dir / "skills" if skills_dir.is_dir(): - return _load_skills_from_skills_dir(skills_dir) + # Non-recursive per Agent Plugins §5: nested .md files are skill + # resources (e.g. references/), not additional skills. + repo, knowledge, agent = load_skills_from_dir( + skills_dir, strict=False, recursive=False + ) + skills = [*repo.values(), *knowledge.values(), *agent.values()] + # Categorization groups skills by type; restore on-disk order. + return sorted(skills, key=lambda s: Path(s.source or "")) root_skill_md = find_skill_md(plugin_dir) if root_skill_md is not None: @@ -203,32 +210,6 @@ def _read_command_definitions(root: Path) -> list[CommandDefinition]: return commands -def _load_skills_from_skills_dir(skills_dir: Path) -> list[Skill]: - """Load every skill under a plugin's ``skills/`` directory.""" - skills: list[Skill] = [] - for item in sorted(skills_dir.iterdir()): - if item.is_dir(): - skill_md = find_skill_md(item) - if skill_md: - try: - # Skill.load() discovers resources, no need to do it again - skill = Skill.load(skill_md, skills_dir, strict=False) - skills.append(skill) - logger.debug(f"Loaded skill: {skill.name} from {skill_md}") - except Exception as e: - logger.warning(f"Failed to load skill from {item}: {e}") - elif item.suffix == ".md" and item.name.lower() != "readme.md": - # Also support single .md files in skills/ directory - try: - skill = Skill.load(item, skills_dir, strict=False) - skills.append(skill) - logger.debug(f"Loaded skill: {skill.name} from {item}") - except Exception as e: - logger.warning(f"Failed to load skill from {item}: {e}") - - return skills - - def _load_root_skill(plugin_dir: Path, skill_md: Path) -> list[Skill]: """Load a single-skill plugin whose ``SKILL.md`` lives at the plugin root. diff --git a/openhands-sdk/openhands/sdk/skills/skill.py b/openhands-sdk/openhands/sdk/skills/skill.py index f2dde7e9e3..594b8fab3d 100644 --- a/openhands-sdk/openhands/sdk/skills/skill.py +++ b/openhands-sdk/openhands/sdk/skills/skill.py @@ -847,6 +847,8 @@ def render_content( def load_skills_from_dir( skill_dir: str | Path, + strict: bool = True, + recursive: bool = True, ) -> tuple[dict[str, Skill], dict[str, Skill], dict[str, Skill]]: """Load all skills from the given directory. @@ -856,8 +858,13 @@ def load_skills_from_dir( Note, legacy repo instructions will not be loaded here. + A skill that fails to load is logged and skipped; it never aborts the others. + Args: skill_dir: Path to the skills directory (e.g. .openhands/skills) + strict: If True, enforce strict AgentSkills name validation. + recursive: If False, only load regular .md files that are immediate + children of skill_dir. Returns: Tuple of (repo_skills, knowledge_skills, agent_skills) dictionaries. @@ -879,25 +886,35 @@ def load_skills_from_dir( # doesn't exist. skill_md_files = find_skill_md_directories(skill_dir) skill_md_dirs = {skill_md.parent for skill_md in skill_md_files} - regular_md_files = find_regular_md_files(skill_dir, skill_md_dirs) + regular_md_files = find_regular_md_files(skill_dir, skill_md_dirs, recursive) # Load SKILL.md files (auto-detected and validated in Skill.load) # Wrap each load in try/except to ensure one bad skill doesn't break all loading for skill_md_path in skill_md_files: try: load_and_categorize( - skill_md_path, skill_dir, repo_skills, knowledge_skills, agent_skills + skill_md_path, + skill_dir, + repo_skills, + knowledge_skills, + agent_skills, + strict=strict, ) - except (SkillError, OSError, yaml.YAMLError) as e: + except Exception as e: logger.warning(f"Failed to load skill from {skill_md_path}: {e}") # Load regular .md files for path in regular_md_files: try: load_and_categorize( - path, skill_dir, repo_skills, knowledge_skills, agent_skills + path, + skill_dir, + repo_skills, + knowledge_skills, + agent_skills, + strict=strict, ) - except (SkillError, OSError, yaml.YAMLError) as e: + except Exception as e: logger.warning(f"Failed to load skill from {path}: {e}") total = len(repo_skills) + len(knowledge_skills) + len(agent_skills) diff --git a/openhands-sdk/openhands/sdk/skills/utils.py b/openhands-sdk/openhands/sdk/skills/utils.py index 39bbd4d5a8..0b940e8112 100644 --- a/openhands-sdk/openhands/sdk/skills/utils.py +++ b/openhands-sdk/openhands/sdk/skills/utils.py @@ -458,12 +458,16 @@ def find_skill_md_directories(skill_dir: Path) -> list[Path]: return results -def find_regular_md_files(skill_dir: Path, exclude_dirs: set[Path]) -> list[Path]: +def find_regular_md_files( + skill_dir: Path, exclude_dirs: set[Path], recursive: bool = True +) -> list[Path]: """Find regular .md skill files, excluding SKILL.md and files in excluded dirs. Args: skill_dir: Path to the skills directory. exclude_dirs: Set of directories to exclude (e.g., SKILL.md directories). + recursive: If False, only scan the immediate children of skill_dir, + where every .md file except a README is a skill. Returns: List of paths to regular .md skill files. @@ -471,6 +475,12 @@ def find_regular_md_files(skill_dir: Path, exclude_dirs: set[Path]) -> list[Path files: list[Path] = [] if not skill_dir.exists(): return files + if not recursive: + return [ + f + for f in sorted(skill_dir.glob("*.md")) + if f.is_file() and f.name.lower() != "readme.md" + ] for f in sorted(skill_dir.rglob("*.md")): is_readme = f.name == "README.md" is_skill_md = f.name.lower() == "skill.md" @@ -486,6 +496,7 @@ def load_and_categorize( repo_skills: dict[str, Skill], knowledge_skills: dict[str, Skill], agent_skills: dict[str, Skill], + strict: bool = True, ) -> None: """Load a skill and categorize it. @@ -497,11 +508,12 @@ def load_and_categorize( repo_skills: Dictionary for skills with trigger=None (permanent context). knowledge_skills: Dictionary for skills with triggers (progressive). agent_skills: Dictionary for AgentSkills standard SKILL.md files. + strict: If True, enforce strict AgentSkills name validation. """ # Import here to avoid circular dependency from openhands.sdk.skills.skill import Skill - skill = Skill.load(path, skill_base_dir) + skill = Skill.load(path, skill_base_dir, strict=strict) # AgentSkills (SKILL.md directories) are a separate category from OpenHands skills. # They follow the AgentSkills standard and should be handled differently. diff --git a/tests/sdk/plugin/test_plugin_loading.py b/tests/sdk/plugin/test_plugin_loading.py index a0c83cb093..e73bf7d913 100644 --- a/tests/sdk/plugin/test_plugin_loading.py +++ b/tests/sdk/plugin/test_plugin_loading.py @@ -229,6 +229,39 @@ def test_load_plugin_no_skills_anywhere(self, tmp_path: Path): assert plugin.skills == [] + def test_skills_dir_nested_md_and_relaxed_names(self, tmp_path: Path): + """Nested .md files are skill resources, not skills; names are relaxed.""" + plugin_dir = tmp_path / "nested" + skill_dir = plugin_dir / "skills" / "Summarize_Tool" + (skill_dir / "references").mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\nname: Summarize_Tool\ndescription: d\n---\nbody\n" + ) + (skill_dir / "references" / "notes.md").write_text("# notes\n") + (plugin_dir / "skills" / "shared").mkdir() + (plugin_dir / "skills" / "shared" / "notes.md").write_text("# notes\n") + (plugin_dir / "skills" / "loose.md").write_text("loose skill\n") + (plugin_dir / "skills" / "readme.md").write_text("# readme\n") + + plugin = Plugin.load(plugin_dir) + + assert [s.name for s in plugin.skills] == ["Summarize_Tool", "loose"] + + def test_skills_dir_bad_skill_skips_only_itself(self, tmp_path: Path): + """A skill that fails to load does not abort the remaining skills.""" + plugin_dir = tmp_path / "partial" + skills_dir = plugin_dir / "skills" + (skills_dir / "broken").mkdir(parents=True) + (skills_dir / "broken" / "SKILL.md").write_bytes(b"\xff\xfe\xfa") + (skills_dir / "good").mkdir() + (skills_dir / "good" / "SKILL.md").write_text( + "---\nname: good\ndescription: d\n---\nbody\n" + ) + + plugin = Plugin.load(plugin_dir) + + assert [s.name for s in plugin.skills] == ["good"] + def test_load_plugin_with_hooks(self, tmp_path: Path): """Test loading a plugin with hooks.""" plugin_dir = tmp_path / "hook-plugin" From b553bb47741f4bd44749513a41e69e76073ca42f Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Wed, 16 Sep 2026 23:01:55 +0200 Subject: [PATCH 5/6] Prune verified model lists to the two latest versions per line (#5102) Co-authored-by: smolpaws --- .../openhands/sdk/agent/acp_agent.py | 2 +- .../openhands/sdk/agent/acp_models.py | 2 +- openhands-sdk/openhands/sdk/agent/agent.py | 2 +- openhands-sdk/openhands/sdk/llm/llm.py | 6 +- .../openhands/sdk/llm/utils/AGENTS.md | 13 ++ .../sdk/llm/utils/verified_models.py | 158 +++++------------- tests/sdk/llm/test_model_list.py | 60 +++++-- 7 files changed, 110 insertions(+), 133 deletions(-) create mode 100644 openhands-sdk/openhands/sdk/llm/utils/AGENTS.md diff --git a/openhands-sdk/openhands/sdk/agent/acp_agent.py b/openhands-sdk/openhands/sdk/agent/acp_agent.py index 72990002a9..4c0de10041 100644 --- a/openhands-sdk/openhands/sdk/agent/acp_agent.py +++ b/openhands-sdk/openhands/sdk/agent/acp_agent.py @@ -4391,7 +4391,7 @@ def set_acp_model(self, model: str) -> None: Args: model: Provider-specific model id to switch to (e.g. - ``"sonnet"`` or ``"gpt-5.5"``). + ``"sonnet"`` or ``"gpt-5.6"``). Raises: ValueError: If ``model`` is empty or whitespace-only, if the diff --git a/openhands-sdk/openhands/sdk/agent/acp_models.py b/openhands-sdk/openhands/sdk/agent/acp_models.py index 9377d705a8..6c9001f4e1 100644 --- a/openhands-sdk/openhands/sdk/agent/acp_models.py +++ b/openhands-sdk/openhands/sdk/agent/acp_models.py @@ -34,7 +34,7 @@ class ACPModelInfo(BaseModel): model_id: str = Field( description=( "Server-assigned model identifier. May be concrete " - '(e.g. ``"gpt-5.5"``) or an opaque alias ' + '(e.g. ``"gpt-5.6"``) or an opaque alias ' '(e.g. ``"default"``, ``"auto"``). This is the value to pass back ' "to the server to switch to this model." ), diff --git a/openhands-sdk/openhands/sdk/agent/agent.py b/openhands-sdk/openhands/sdk/agent/agent.py index 2d4054faf4..6f4a9db989 100644 --- a/openhands-sdk/openhands/sdk/agent/agent.py +++ b/openhands-sdk/openhands/sdk/agent/agent.py @@ -398,7 +398,7 @@ class Agent(CriticMixin, ResponseDispatchMixin, AgentBase): from openhands.sdk import LLM, Agent, Tool from pydantic import SecretStr - llm = LLM(model="gpt-5.5", api_key=SecretStr("key")) + llm = LLM(model="gpt-5.6", api_key=SecretStr("key")) tools = [Tool(name="TerminalTool"), Tool(name="FileEditorTool")] agent = Agent(llm=llm, tools=tools) ``` diff --git a/openhands-sdk/openhands/sdk/llm/llm.py b/openhands-sdk/openhands/sdk/llm/llm.py index 7bf9f3d543..74346ce71c 100644 --- a/openhands-sdk/openhands/sdk/llm/llm.py +++ b/openhands-sdk/openhands/sdk/llm/llm.py @@ -226,7 +226,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): API authentication, retry logic, and tool calling capabilities. Attributes: - model: Model name (e.g., "gpt-5.5"). + model: Model name (e.g., "gpt-5.6"). api_key: API key for authentication. base_url: Custom API base URL. num_retries: Number of retry attempts for failed requests. @@ -238,7 +238,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): from pydantic import SecretStr llm = LLM( - model="gpt-5.5", + model="gpt-5.6", api_key=SecretStr("your-api-key"), usage_id="my-agent" ) @@ -251,7 +251,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): # ========================================================================= model: str = Field( - default="gpt-5.5", + default="gpt-5.6", description="Model name.", json_schema_extra=field_meta(SettingProminence.CRITICAL), ) diff --git a/openhands-sdk/openhands/sdk/llm/utils/AGENTS.md b/openhands-sdk/openhands/sdk/llm/utils/AGENTS.md new file mode 100644 index 0000000000..a4c5ef01d3 --- /dev/null +++ b/openhands-sdk/openhands/sdk/llm/utils/AGENTS.md @@ -0,0 +1,13 @@ +# LLM utils guidelines + +See the [SDK AGENTS.md](../../AGENTS.md) for package-wide policies. + +## Verified model lists (`verified_models.py`) + +These lists are a curated set of models that work well, not a catalog of everything a provider offers. Keep them short. + +- For each model line, keep only the **two latest versions** (for example `gpt-6` and `gpt-5.6`; `claude-opus-5` and `claude-opus-4-8`). +- Variants of a kept version (`-pro`, `-mini`, `-codex`, `-flash`, dated aliases) stay with that version. Unversioned "current" aliases (`deepseek-chat`, `kimi-for-coding`) stay. +- When you add a new version, remove the oldest version in the same line, in every list where it appears (the provider list and `VERIFIED_OPENHANDS_MODELS`). +- Every entry in `VERIFIED_OPENHANDS_MODELS` must also appear in a provider list, unless it is OpenHands-only; `tests/sdk/llm/test_model_list.py` checks this. +- Do not add a model just because LiteLLM or OpenRouter knows about it. The unverified catalog (`unverified_models.py`) covers that. diff --git a/openhands-sdk/openhands/sdk/llm/utils/verified_models.py b/openhands-sdk/openhands/sdk/llm/utils/verified_models.py index 3a0cc89e14..2958a166fa 100644 --- a/openhands-sdk/openhands/sdk/llm/utils/verified_models.py +++ b/openhands-sdk/openhands/sdk/llm/utils/verified_models.py @@ -1,212 +1,146 @@ +"""Curated lists of models that are known to work well with OpenHands. + +These lists feed the model pickers (agent-server ``/llm/verified-models``) and +the ``openhands/`` provider allowlist. They are meant to be short. + +Rule: for each model line, keep only the two latest versions. Variants of a kept +version (``-pro``, ``-mini``, ``-codex``, ``-flash``, dated aliases) stay with it. +Unversioned "current" aliases (for example ``deepseek-chat``, ``kimi-for-coding``) +stay. When a new version lands, drop the oldest one in the same line. +""" + +# GPT: gpt-6 and gpt-5.6. Codex: gpt-5.3-codex and gpt-5.2-codex. VERIFIED_OPENAI_MODELS = [ "gpt-6-astra", "gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", - "gpt-5.5", - "gpt-5.5-pro", - "gpt-5.4", - "gpt-5.4-pro", - "gpt-5.4-mini", - "gpt-5.2", - "gpt-5.2-codex", "gpt-5.3-codex", - "gpt-5.1", - "gpt-5.1-codex-max", - "gpt-5.1-codex", - "gpt-5.1-codex-mini", - "gpt-5-codex", - "gpt-5-2025-08-07", - "gpt-5-mini-2025-08-07", - "o4-mini", - "gpt-4o", - "gpt-4o-mini", - "gpt-4-32k", - "gpt-4.1", - "gpt-4.1-2025-04-14", - "o1-mini", - "o3", - "o3-pro", - "codex-mini-latest", + "gpt-5.2-codex", ] +# Opus: 5 and 4.8. Sonnet: 5 and 4.6. Haiku: 4.5. Fable: 5.1 and 5. VERIFIED_ANTHROPIC_MODELS = [ - "claude-sonnet-4-5-20250929", - "claude-haiku-4-5-20251001", - "claude-opus-4-5-20251101", - "claude-opus-4-5", - "claude-opus-4-6", - "claude-opus-4-7", - "claude-opus-4-8", "claude-opus-5", - "claude-fable-5", - "claude-fable-5-1", + "claude-opus-4-8", "claude-sonnet-5", - "claude-sonnet-4-5", "claude-sonnet-4-6", - "claude-sonnet-4-20250514", - "claude-opus-4-20250514", - "claude-opus-4-1-20250805", - "claude-3-7-sonnet-20250219", - "claude-3-sonnet-20240229", - "claude-3-opus-20240229", - "claude-3-haiku-20240307", - "claude-3-5-haiku-20241022", - "claude-3-5-sonnet-20241022", - "claude-3-5-sonnet-20240620", + "claude-haiku-4-5-20251001", + "claude-fable-5-1", + "claude-fable-5", ] +# Devstral 2512 (two sizes). VERIFIED_MISTRAL_MODELS = [ - "devstral-small-2505", - "devstral-small-2507", - "devstral-medium-2507", "devstral-2512", "devstral-medium-2512", ] +# Pro: 3.1. Flash: 3.8 and 3.7. Flash-lite: 3.5 and 3.1. VERIFIED_GEMINI_MODELS = [ - "gemini-3.1-pro-preview", "gemini-3.1-pro", - "gemini-3.1-flash-lite", + "gemini-3.1-pro-preview", "gemini-3.8-flash", "gemini-3.7-flash", - "gemini-3.6-flash", - "gemini-3.5-flash", "gemini-3.5-flash-lite", - "gemini-3-flash", - "gemini-3-pro", + "gemini-3.1-flash-lite", ] +# V4 and V3.2; ``deepseek-chat`` is the current alias. VERIFIED_DEEPSEEK_MODELS = [ "deepseek-chat", - "deepseek-v3.2-reasoner", "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp", + "deepseek-v3.2-reasoner", ] +# Kimi K3 and K2.7; ``kimi-for-coding`` is Moonshot's own alias (direct API only; +# the OpenHands proxy does not serve it, so it is not in the openhands list). VERIFIED_MOONSHOT_MODELS = [ "kimi-k3", - "kimi-k2-thinking", "kimi-k2.7-code", - "kimi-k2.6", - "kimi-k2.5", "kimi-for-coding", ] +# M3 and M2.7. VERIFIED_MINIMAX_MODELS = [ - "minimax-m2.1", - "minimax-m2.5", - "minimax-m2.7", "minimax-m3", + "minimax-m2.7", ] +# GLM 5.3 and 5.2. VERIFIED_GLM_MODELS = [ - "glm-4.7", - "glm-4.7-flash", - "glm-5", - "glm-5.1", - "glm-5.2", "glm-5.3", "glm-5.3-flash", + "glm-5.2", ] +# Nemotron 3.5 and 3. VERIFIED_NVIDIA_MODELS = [ + "nemotron-3.5-lightning-30b-a3b", "nemotron-3-nano", "nemotron-3-super-120b-a12b", "nemotron-3-ultra-550b-a55b", - "nemotron-3.5-lightning-30b-a3b", ] +# Plus: 3.7 and 3.6. Max: 3.8 and 3.7. Flash: 3.8 and 3.7. Coder: unversioned. VERIFIED_QWEN_MODELS = [ - "qwen3-6-plus", - "qwen3.5-plus", - "qwen3.6-plus", "qwen3.7-plus", + "qwen3.6-plus", "qwen3.8-max", "qwen3.7-max", - "qwen3-max", "qwen3.8-flash", "qwen3.7-flash", - "qwen3.6-flash", - "qwen3.5-flash", "qwen3-coder-480b", "qwen3-coder-next", "qwen3-coder-plus", "qwen3-coder-flash", ] +# What the ``openhands/`` provider serves. Same rule; every entry must also be in +# a provider list above, except OpenHands-only models. VERIFIED_OPENHANDS_MODELS = [ - "claude-opus-4-5-20251101", - "claude-opus-4-6", - "claude-opus-4-7", - "claude-opus-4-8", "claude-opus-5", - "claude-fable-5", - "claude-fable-5-1", + "claude-opus-4-8", "claude-sonnet-5", - "claude-sonnet-4-5", "claude-sonnet-4-6", + "claude-fable-5-1", + "claude-fable-5", "gpt-6-astra", "gpt-5.6", - "gpt-5.5", - "gpt-5.5-pro", - "gpt-5.4", - "gpt-5.4-pro", - "gpt-5.2", - "gpt-5.2-codex", "gpt-5.3-codex", - "minimax-m2.1", - "minimax-m2.5", - "minimax-m2.7", + "gpt-5.2-codex", "minimax-m3", + "minimax-m2.7", "gemini-3.1-pro", "gemini-3.1-pro-preview", "gemini-3.8-flash", "gemini-3.7-flash", - "gemini-3.6-flash", - "gemini-3.5-flash", "gemini-3.5-flash-lite", - "gemini-3-flash", - "gemini-3-pro", "deepseek-chat", - "deepseek-v3.2-reasoner", "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp", + "deepseek-v3.2-reasoner", "kimi-k3", - "kimi-k2-thinking", "kimi-k2.7-code", - "kimi-k2.6", - "kimi-k2.5", - "devstral-medium-2512", "devstral-2512", - "gpt-5.1-codex-max", - "gpt-5.1-codex", - "gpt-5.1", - "o3-pro", - "glm-4.7", - "glm-5", - "glm-5.1", - "glm-5.2", + "devstral-medium-2512", "glm-5.3", "glm-5.3-flash", + "glm-5.2", + "nemotron-3.5-lightning-30b-a3b", "nemotron-3-nano", "nemotron-3-super-120b-a12b", "nemotron-3-ultra-550b-a55b", - "nemotron-3.5-lightning-30b-a3b", - "qwen3-6-plus", - "qwen3.5-plus", - "qwen3.6-plus", "qwen3.7-plus", + "qwen3.6-plus", "qwen3.8-max", "qwen3.7-max", - "qwen3-max", "qwen3.8-flash", "qwen3.7-flash", - "qwen3.6-flash", - "qwen3.5-flash", "qwen3-coder-480b", "qwen3-coder-next", "qwen3-coder-plus", diff --git a/tests/sdk/llm/test_model_list.py b/tests/sdk/llm/test_model_list.py index 1f2a2efda6..e33bcc61f4 100644 --- a/tests/sdk/llm/test_model_list.py +++ b/tests/sdk/llm/test_model_list.py @@ -14,12 +14,12 @@ def test_organize_models_and_providers(): models = [ - "openai/gpt-4o", - "anthropic/claude-sonnet-4-20250514", - "o3", - "o4-mini", - "devstral-small-2505", - "mistral/devstral-small-2505", + "openai/gpt-5.6", + "anthropic/claude-sonnet-5", + "gpt-5.3-codex", + "gpt-6-astra", + "devstral-2512", + "mistral/devstral-2512", "anthropic.claude-3-5", # Ignore dot separator for anthropic "unknown-model", "custom-provider/custom-model", # invalid provider -> bucketed under "other" @@ -139,16 +139,46 @@ def test_nemotron_3_super_uses_full_infra_name(): ) -def test_claude_opus_4_5_uses_full_infra_name(): - """The OpenHands proxy serves the dated snapshot ``claude-opus-4-5-20251101``; - the bare alias ``claude-opus-4-5`` is not a valid proxy model name and must - not be offered under the OpenHands provider. +def test_openhands_haiku_uses_full_infra_name(): + """The OpenHands proxy serves dated snapshots for some Anthropic models + (``claude-haiku-4-5-20251001``); bare aliases that the proxy does not know + must not be offered under the OpenHands provider. """ - assert "claude-opus-4-5-20251101" in VERIFIED_OPENHANDS_MODELS - # Scope is intentionally narrower than test_nemotron_3_super_uses_full_infra_name - # (which loops over all providers): VERIFIED_ANTHROPIC_MODELS legitimately keeps - # the bare alias because direct-Anthropic BYOK may accept it. - assert "claude-opus-4-5" not in VERIFIED_OPENHANDS_MODELS + assert "claude-haiku-4-5" not in VERIFIED_OPENHANDS_MODELS + # VERIFIED_ANTHROPIC_MODELS keeps the dated name; direct-Anthropic BYOK is fine. + assert "claude-haiku-4-5-20251001" in VERIFIED_MODELS["anthropic"] + + +def test_verified_lists_keep_two_latest_versions_per_line(): + """Check the curation rule for every provider: the two latest versions of a + line are present and the version before them is gone (see the module + docstring and ``llm/utils/AGENTS.md``). Update the table when a new version + lands. + """ + expectations = { + "openai": ({"gpt-6-astra", "gpt-5.6"}, {"gpt-5.5", "gpt-5.4", "gpt-4o", "o3"}), + "anthropic": ({"claude-opus-5", "claude-opus-4-8"}, {"claude-opus-4-7"}), + "mistral": ( + {"devstral-2512", "devstral-medium-2512"}, + {"devstral-medium-2507"}, + ), + "gemini": ({"gemini-3.8-flash", "gemini-3.7-flash"}, {"gemini-3.6-flash"}), + "deepseek": ({"deepseek-v4-pro", "deepseek-v3.2-reasoner"}, set()), + "moonshot": ({"kimi-k3", "kimi-k2.7-code"}, {"kimi-k2.6"}), + "minimax": ({"minimax-m3", "minimax-m2.7"}, {"minimax-m2.5"}), + "glm": ({"glm-5.3", "glm-5.2"}, {"glm-5.1"}), + "nvidia": ({"nemotron-3.5-lightning-30b-a3b", "nemotron-3-nano"}, set()), + "qwen": ({"qwen3.8-max", "qwen3.7-max"}, {"qwen3-max", "qwen3-6-plus"}), + } + assert set(expectations) == set(VERIFIED_MODELS) - {"openhands"} + for provider, (present, absent) in expectations.items(): + models = set(VERIFIED_MODELS[provider]) + assert present <= models, f"{provider}: missing {present - models}" + assert not (absent & models), f"{provider}: stale {absent & models}" + assert {"gpt-6-astra", "gpt-5.6", "claude-opus-5"} <= set(VERIFIED_OPENHANDS_MODELS) + assert not {"gpt-5.5", "claude-opus-4-7", "minimax-m2.5"} & set( + VERIFIED_OPENHANDS_MODELS + ) def test_trinity_model_is_openhands_only(): From 97dbce5c37865dd7a3020d2b9662f4a76d11fa71 Mon Sep 17 00:00:00 2001 From: OpenHands Bot Date: Wed, 16 Sep 2026 17:28:40 -0400 Subject: [PATCH 6/6] Release v1.49.0 (#5103) Co-authored-by: github-actions[bot] Co-authored-by: openhands Co-authored-by: Engel Nyst --- clients/typescript/AGENTS.md | 2 +- clients/typescript/package-lock.json | 4 +-- clients/typescript/package.json | 2 +- .../typescript/src/client/desktop-client.ts | 30 ------------------- .../typescript/src/client/openhands-client.ts | 3 -- clients/typescript/src/clients.ts | 2 -- .../src/conversation/conversation-manager.ts | 4 --- clients/typescript/src/index.ts | 1 - clients/typescript/src/models/api.ts | 4 --- .../openhands/agent_server/init_router.py | 2 +- .../openhands/agent_server/runtime_router.py | 2 +- .../agent_server/server_details_router.py | 2 +- openhands-agent-server/pyproject.toml | 2 +- openhands-sdk/pyproject.toml | 2 +- openhands-tools/pyproject.toml | 2 +- openhands-workspace/pyproject.toml | 2 +- uv.lock | 8 ++--- 17 files changed, 15 insertions(+), 59 deletions(-) delete mode 100644 clients/typescript/src/client/desktop-client.ts diff --git a/clients/typescript/AGENTS.md b/clients/typescript/AGENTS.md index cc37c31533..85b7f6aa8e 100644 --- a/clients/typescript/AGENTS.md +++ b/clients/typescript/AGENTS.md @@ -229,7 +229,7 @@ await conversation.close(); **Factory Functions**: -**Ergonomic API note**: Keep `ConversationManager` as the main server-scoped entry point. Server/LLM/settings/skills/tools/VSCode/desktop operations should be reachable through manager namespaces such as `manager.server`, `manager.llm`, and `manager.desktop`; ACP-specific operations should be reachable via `manager.acp`. +**Ergonomic API note**: Keep `ConversationManager` as the main server-scoped entry point. Server/LLM/settings/skills/tools/VSCode operations should be reachable through manager namespaces such as `manager.server`, `manager.llm`, and `manager.vscode`; ACP-specific operations should be reachable via `manager.acp`. - `createConversation({ type, agent, workspace, options })` - Explicit type selection - `createConversationAuto(agent, workspace, options)` - Auto-detect based on workspace type diff --git a/clients/typescript/package-lock.json b/clients/typescript/package-lock.json index da55b8676c..30fe5ba676 100644 --- a/clients/typescript/package-lock.json +++ b/clients/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "@openhands/typescript-client", - "version": "1.48.0", + "version": "1.49.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@openhands/typescript-client", - "version": "1.48.0", + "version": "1.49.0", "license": "MIT", "dependencies": { "@openrouter/sdk": "^1.2.11", diff --git a/clients/typescript/package.json b/clients/typescript/package.json index 20b09a81ef..fc905218a0 100644 --- a/clients/typescript/package.json +++ b/clients/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@openhands/typescript-client", - "version": "1.48.0", + "version": "1.49.0", "description": "TypeScript client for OpenHands Agent Server", "main": "dist/index.js", "module": "dist/index.js", diff --git a/clients/typescript/src/client/desktop-client.ts b/clients/typescript/src/client/desktop-client.ts deleted file mode 100644 index 0d93d7e6a2..0000000000 --- a/clients/typescript/src/client/desktop-client.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { createRuntimeHttpClients } from './runtime-transport'; -import type { RuntimeServiceClientOptions } from './runtime-transport'; -import type { HttpClient } from './http-client'; -import { DesktopUrlResponse } from '../models/api'; - -export type DesktopClientOptions = RuntimeServiceClientOptions; - -export class DesktopClient { - public readonly host: string; - public readonly apiKey?: string; - private readonly client: HttpClient; - - constructor(options: DesktopClientOptions) { - const { runtimeClient } = createRuntimeHttpClients(options); - this.host = options.host.replace(/\/$/, ''); - this.apiKey = options.apiKey; - this.client = runtimeClient; - } - - async getUrl(baseUrl?: string): Promise { - const response = await this.client.get('/api/desktop/url', { - params: baseUrl ? { base_url: baseUrl } : undefined, - }); - return response.data.url; - } - - close(): void { - this.client.close(); - } -} diff --git a/clients/typescript/src/client/openhands-client.ts b/clients/typescript/src/client/openhands-client.ts index d01e1ba34e..e3accc140a 100644 --- a/clients/typescript/src/client/openhands-client.ts +++ b/clients/typescript/src/client/openhands-client.ts @@ -1,7 +1,6 @@ import { AgentProfilesClient } from './agent-profiles-client'; import { BashClient } from './bash-client'; import { ConversationClient } from './conversation-client'; -import { DesktopClient } from './desktop-client'; import { FileClient } from './file-client'; import { HooksClient } from './hooks-client'; import { HttpClient, type ResponseType } from './http-client'; @@ -136,7 +135,6 @@ export class AgentServerClient extends OpenHandsClient { readonly plugins: PluginsClient; readonly tools: ToolClient; readonly vscode: VSCodeClient; - readonly desktop: DesktopClient; readonly shared: SharedClient; readonly llm: LLMMetadataClient; readonly workspaces: WorkspacesClient; @@ -171,7 +169,6 @@ export class AgentServerClient extends OpenHandsClient { this.plugins = new PluginsClient(clientOptions); this.tools = new ToolClient(clientOptions); this.vscode = new VSCodeClient(clientOptions); - this.desktop = new DesktopClient(clientOptions); this.shared = new SharedClient(clientOptions); this.llm = new LLMMetadataClient(clientOptions); this.workspaces = new WorkspacesClient(clientOptions); diff --git a/clients/typescript/src/clients.ts b/clients/typescript/src/clients.ts index 4e345376d1..c92e60d20c 100644 --- a/clients/typescript/src/clients.ts +++ b/clients/typescript/src/clients.ts @@ -14,7 +14,6 @@ export { SubAgentsClient } from './client/sub-agents-client'; export { PluginsClient } from './client/plugins-client'; export { ToolClient } from './client/tool-client'; export { VSCodeClient } from './client/vscode-client'; -export { DesktopClient } from './client/desktop-client'; export { SharedClient } from './client/shared-client'; export { WorkspacesClient } from './client/workspaces-client'; export { AgentServerClient, OpenHandsClient } from './client/openhands-client'; @@ -71,7 +70,6 @@ export type { SubAgentsClientOptions } from './client/sub-agents-client'; export type { PluginsClientOptions } from './client/plugins-client'; export type { ToolClientOptions } from './client/tool-client'; export type { VSCodeClientOptions, GetVSCodeUrlOptions } from './client/vscode-client'; -export type { DesktopClientOptions } from './client/desktop-client'; export type { SharedClientOptions, SharedEventSearchOptions } from './client/shared-client'; export type { DeleteWorkspaceResponse, diff --git a/clients/typescript/src/conversation/conversation-manager.ts b/clients/typescript/src/conversation/conversation-manager.ts index 823be294f1..8ce95d6eae 100644 --- a/clients/typescript/src/conversation/conversation-manager.ts +++ b/clients/typescript/src/conversation/conversation-manager.ts @@ -4,7 +4,6 @@ import { HttpClient } from '../client/http-client'; import { AgentProfilesClient } from '../client/agent-profiles-client'; -import { DesktopClient } from '../client/desktop-client'; import { FileClient } from '../client/file-client'; import { HooksClient } from '../client/hooks-client'; import { LLMMetadataClient } from '../client/llm-client'; @@ -90,7 +89,6 @@ export class ConversationManager { public readonly subAgents: SubAgentsClient; public readonly tools: ToolClient; public readonly vscode: VSCodeClient; - public readonly desktop: DesktopClient; public readonly files: FileClient; public readonly workspaces: WorkspacesClient; public readonly shared: SharedClient; @@ -123,7 +121,6 @@ export class ConversationManager { this.subAgents = new SubAgentsClient(clientOptions); this.tools = new ToolClient(clientOptions); this.vscode = new VSCodeClient(clientOptions); - this.desktop = new DesktopClient(clientOptions); this.files = new FileClient(clientOptions); this.workspaces = new WorkspacesClient(clientOptions); this.shared = new SharedClient(clientOptions); @@ -408,7 +405,6 @@ export class ConversationManager { this.subAgents.close(); this.tools.close(); this.vscode.close(); - this.desktop.close(); this.files.close(); this.workspaces.close(); this.shared.close(); diff --git a/clients/typescript/src/index.ts b/clients/typescript/src/index.ts index b6e4b1d30d..a7ab8d3aad 100644 --- a/clients/typescript/src/index.ts +++ b/clients/typescript/src/index.ts @@ -373,7 +373,6 @@ export type { TogglePluginResponse, PluginActionResponse, RefreshPluginResponse, - DesktopUrlResponse, VSCodeUrlResponse, VSCodeStatusResponse, ProfileInfo, diff --git a/clients/typescript/src/models/api.ts b/clients/typescript/src/models/api.ts index b0c44c6d11..48f61030af 100644 --- a/clients/typescript/src/models/api.ts +++ b/clients/typescript/src/models/api.ts @@ -278,10 +278,6 @@ export interface RefreshPluginResponse { plugin: InstalledPluginInfo; } -export interface DesktopUrlResponse { - url: string | null; -} - export interface VSCodeUrlResponse { url: string | null; } diff --git a/openhands-agent-server/openhands/agent_server/init_router.py b/openhands-agent-server/openhands/agent_server/init_router.py index 8bdc002c71..ff420311f2 100644 --- a/openhands-agent-server/openhands/agent_server/init_router.py +++ b/openhands-agent-server/openhands/agent_server/init_router.py @@ -1,7 +1,7 @@ """Deferred-init router for warm-pool agent servers. When ``Config.deferred_init`` is True the server starts in *dormant* mode: -stateless services (VSCode, desktop, tool preload) come up as usual, but +stateless services (VSCode, tool preload) come up as usual, but the conversation, event, and bash routers return 503 until ``POST /api/init`` delivers the runtime configuration. This is intended for warm-pool deployments where pods are pre-warmed before a user is matched and the diff --git a/openhands-agent-server/openhands/agent_server/runtime_router.py b/openhands-agent-server/openhands/agent_server/runtime_router.py index aa8b75137a..a12f0ee388 100644 --- a/openhands-agent-server/openhands/agent_server/runtime_router.py +++ b/openhands-agent-server/openhands/agent_server/runtime_router.py @@ -1,6 +1,6 @@ """Conversation-addressed APIs with workspace and terminal-history context. -Local processes and desktop/VSCode services still share the host; these path +Local processes and VSCode services still share the host; these path checks are routing safeguards, not a sandbox for arbitrary shell commands. """ diff --git a/openhands-agent-server/openhands/agent_server/server_details_router.py b/openhands-agent-server/openhands/agent_server/server_details_router.py index f4ee7e8591..011b99f88d 100644 --- a/openhands-agent-server/openhands/agent_server/server_details_router.py +++ b/openhands-agent-server/openhands/agent_server/server_details_router.py @@ -85,7 +85,7 @@ def update_last_execution_time(): def mark_initialization_complete() -> None: """Mark the server as fully initialized and ready to serve requests. - This should be called after all services (VSCode, desktop, tool preload, etc.) + This should be called after all services (VSCode, tool preload, etc.) have finished initializing. Until this is called, the /ready endpoint will return 503 Service Unavailable. """ diff --git a/openhands-agent-server/pyproject.toml b/openhands-agent-server/pyproject.toml index ecdde20a93..76be661ab7 100644 --- a/openhands-agent-server/pyproject.toml +++ b/openhands-agent-server/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-agent-server" -version = "1.48.0" +version = "1.49.0" description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent" requires-python = ">=3.12" diff --git a/openhands-sdk/pyproject.toml b/openhands-sdk/pyproject.toml index 5a8a7261d8..b3f24ed34f 100644 --- a/openhands-sdk/pyproject.toml +++ b/openhands-sdk/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-sdk" -version = "1.48.0" +version = "1.49.0" description = "OpenHands SDK - Core functionality for building AI agents" requires-python = ">=3.12" diff --git a/openhands-tools/pyproject.toml b/openhands-tools/pyproject.toml index 7d121eb373..50851b3e7b 100644 --- a/openhands-tools/pyproject.toml +++ b/openhands-tools/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-tools" -version = "1.48.0" +version = "1.49.0" description = "OpenHands Tools - Runtime tools for AI agents" requires-python = ">=3.12" diff --git a/openhands-workspace/pyproject.toml b/openhands-workspace/pyproject.toml index 591e51dab4..64931e7183 100644 --- a/openhands-workspace/pyproject.toml +++ b/openhands-workspace/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-workspace" -version = "1.48.0" +version = "1.49.0" description = "OpenHands Workspace - Docker and container-based workspace implementations" requires-python = ">=3.12" diff --git a/uv.lock b/uv.lock index c77f11f1c1..0b55ad0c47 100644 --- a/uv.lock +++ b/uv.lock @@ -2774,7 +2774,7 @@ wheels = [ [[package]] name = "openhands-agent-server" -version = "1.48.0" +version = "1.49.0" source = { editable = "openhands-agent-server" } dependencies = [ { name = "aiosqlite" }, @@ -2814,7 +2814,7 @@ provides-extras = ["posthog"] [[package]] name = "openhands-sdk" -version = "1.48.0" +version = "1.49.0" source = { editable = "openhands-sdk" } dependencies = [ { name = "agent-client-protocol" }, @@ -2876,7 +2876,7 @@ provides-extras = ["boto3", "toolshield", "vertex"] [[package]] name = "openhands-tools" -version = "1.48.0" +version = "1.49.0" source = { editable = "openhands-tools" } dependencies = [ { name = "binaryornot" }, @@ -2907,7 +2907,7 @@ requires-dist = [ [[package]] name = "openhands-workspace" -version = "1.48.0" +version = "1.49.0" source = { editable = "openhands-workspace" } dependencies = [ { name = "openhands-agent-server" },