Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 84 additions & 16 deletions openhands-agent-server/openhands/agent_server/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@
)
from openhands.agent_server.desktop_router import desktop_router
from openhands.agent_server.desktop_service import get_desktop_service
from openhands.agent_server.docker_runtime import DockerConversationRegistry
from openhands.agent_server.docker_runtime.routers import (
docker_conversation_proxy_router,
docker_global_proxy_router,
docker_sockets_router,
docker_workspace_proxy_router,
)
from openhands.agent_server.event_router import event_router
from openhands.agent_server.file_router import file_router
from openhands.agent_server.git_router import git_router
Expand Down Expand Up @@ -203,9 +210,28 @@ async def start_tool_preload_service():
config.bash_events_retention_seconds,
)

# Docker runtime: per-conversation container registry, backed by
# ``DockerWorkspace``. The proxy routers each construct their own
# short-lived ``httpx.AsyncClient`` per request, so there is no
# shared HTTP client to plumb in here. The in-process
# ``ConversationService`` stays live but runs in
# ``read_only_metadata`` mode — it answers list/count/search/get
# by reading the shared persistence directory off disk while the
# sub-containers own the actual conversations.
docker_registry: DockerConversationRegistry | None = None
if config.conversation_runtime == "docker":
docker_registry = DockerConversationRegistry(config)
api.state.docker_registry = docker_registry
logger.info(
"Docker conversation runtime enabled (image=%s)",
config.conversation_image,
)

try:
yield
finally:
if docker_registry is not None:
await docker_registry.shutdown()
if retention_task is not None:
retention_task.cancel()
with suppress(asyncio.CancelledError):
Expand Down Expand Up @@ -298,27 +324,53 @@ def _add_api_routes(app: FastAPI, config: Config) -> None:
dependencies.append(Depends(create_session_api_key_dependency(config)))

api_router = APIRouter(prefix="/api", dependencies=dependencies)
api_router.include_router(event_router)
api_router.include_router(conversation_router)
api_router.include_router(conversation_router_acp)
api_router.include_router(tool_router)
api_router.include_router(bash_router)
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(hooks_router)
api_router.include_router(llm_router)
api_router.include_router(mcp_router)

if config.conversation_runtime == "docker":
# Docker mode: per-conversation mutations and the global per-host
# routers reverse-proxy to a per-conversation container. Metadata
# (list / count / search / get) and the cross-conversation routers
# (settings, profiles, workspaces, auth, …) still run in-process
# on the outer server, reading the shared on-disk persistence dir.
#
# Order matters:
# 1. ``docker_global_proxy_router`` catches ``/api/{bash,git,
# file,vscode,desktop,hooks,mcp,skills,tools,llm}/...`` and
# forwards them based on the required ``?cid=`` query param.
# For any other path it raises 404 so the local routers get a
# chance to match.
# 2. ``docker_conversation_proxy_router`` claims the mutation
# verbs on ``/api/conversations/...``. It intentionally does
# NOT register ``GET`` on the metadata paths so the local
# ``conversation_router``'s GETs match.
# 3. The unchanged ``conversation_router`` / ``event_router``
# provide GET metadata and serve the workspace static-file
# tree for sub-container conversations via the same routes.
api_router.include_router(docker_global_proxy_router)
api_router.include_router(docker_conversation_proxy_router)
api_router.include_router(event_router)
api_router.include_router(conversation_router)
api_router.include_router(conversation_router_acp)
else:
api_router.include_router(event_router)
api_router.include_router(conversation_router)
api_router.include_router(conversation_router_acp)
api_router.include_router(tool_router)
api_router.include_router(bash_router)
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(hooks_router)
api_router.include_router(mcp_router)
api_router.include_router(llm_router)
api_router.include_router(settings_router)
api_router.include_router(workspaces_router)
api_router.include_router(profiles_router)
api_router.include_router(cloud_proxy_router)
# /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)

# Workspace static-file routes get their own auth group that accepts
# EITHER the X-Session-API-Key header OR the workspace session cookie.
Expand All @@ -331,10 +383,26 @@ def _add_api_routes(app: FastAPI, config: Config) -> None:
Depends(create_workspace_session_dependency(config))
)
workspace_api_router = APIRouter(prefix="/api", dependencies=workspace_dependencies)
workspace_api_router.include_router(workspace_router)
if config.conversation_runtime == "docker":
# Proxy workspace static files via the per-conversation container,
# but under the workspace-cookie auth group so iframe/img embeds work.
workspace_api_router.include_router(docker_workspace_proxy_router)
else:
workspace_api_router.include_router(workspace_router)

# Order matters: the workspace router is more specific
# (``/conversations/{cid}/workspace/...``) than the docker catch-all
# (``/conversations/{cid}/{tail:path}``). Starlette matches in
# registration order, so we MUST include the cookie-auth workspace
# router before the header-auth api_router; otherwise the catch-all
# would shadow workspace requests and demand the header.
app.include_router(workspace_api_router)
app.include_router(api_router)

app.include_router(sockets_router)
if config.conversation_runtime == "docker":
app.include_router(docker_sockets_router)
else:
app.include_router(sockets_router)


def _setup_static_files(app: FastAPI, config: Config) -> None:
Expand Down
71 changes: 70 additions & 1 deletion openhands-agent-server/openhands/agent_server/config.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import logging
import os
from pathlib import Path
from typing import ClassVar
from typing import ClassVar, Literal

from pydantic import BaseModel, ConfigDict, Field, SecretStr

Expand Down Expand Up @@ -212,6 +212,75 @@ class Config(BaseModel):
"The URL where this agent server instance is available externally"
),
)

# ---- Docker runtime mode -----------------------------------------------
# When ``conversation_runtime == "docker"``, every conversation runs in
# its own Docker container hosting another agent-server. This outer
# agent-server then acts as a thin reverse proxy in front of the per-
# conversation containers. See ``docker_runtime/`` for the implementation.
conversation_runtime: Literal["local", "docker"] = Field(
default="local",
description=(
"How to host conversations. ``local`` runs each conversation "
"in-process on this server (the default; current behavior). "
"``docker`` spawns a fresh Docker container running an "
"agent-server image per conversation and proxies "
"conversation-scoped HTTP and WebSocket traffic to it."
),
)
conversation_image: str = Field(
default="ghcr.io/openhands/agent-server:latest-python",
description=(
"Container image used to host each conversation when "
"``conversation_runtime == 'docker'``. Ignored otherwise."
),
)
conversation_container_network: str | None = Field(
default=None,
description=(
"Optional Docker network to attach per-conversation containers to."
),
)
conversation_container_volumes: list[str] = Field(
default_factory=list,
description=(
"Additional ``-v`` volume mounts to apply to every per-conversation "
"container (e.g. ``'/host/cache:/cache'``). Ignored in local mode."
),
)
conversation_container_forward_env: list[str] = Field(
default_factory=lambda: [
"DEBUG",
"OH_SECRET_KEY",
"OH_SESSION_API_KEYS_0",
],
description=(
"Environment variable names to forward from this server's "
"environment into every per-conversation container.\n\n"
"Defaults explained:\n"
"* ``OH_SECRET_KEY`` — outer server and sub-containers share "
"the same persisted settings/secrets directory and must derive "
"the same cipher key, otherwise encrypted values won't "
"round-trip.\n"
"* ``OH_SESSION_API_KEYS_0`` — the outer's reverse-proxy "
"forwards the client's ``X-Session-API-Key`` header verbatim, "
"so the inner must accept the same key. (When the outer has "
"no session-key requirement at all, omitting this is fine.)"
),
)
conversation_container_platform: str = Field(
default="linux/amd64",
description="``--platform`` flag passed to ``docker run``.",
)
conversation_container_startup_timeout: float = Field(
default=120.0,
gt=0.0,
description=(
"Seconds to wait for a freshly-spawned conversation container "
"to pass its /health check before giving up."
),
)

model_config: ClassVar[ConfigDict] = {"frozen": True}

@property
Expand Down
Loading
Loading