diff --git a/examples/02_remote_agent_server/02_convo_with_docker_sandboxed_server.py b/examples/02_remote_agent_server/02_convo_with_docker_sandboxed_server.py index 5a59ed43db..581586859b 100644 --- a/examples/02_remote_agent_server/02_convo_with_docker_sandboxed_server.py +++ b/examples/02_remote_agent_server/02_convo_with_docker_sandboxed_server.py @@ -11,7 +11,7 @@ get_logger, ) from openhands.tools.preset.default import get_default_agent -from openhands.workspace import DockerWorkspace +from openhands.workspace import DockerDevWorkspace logger = get_logger(__name__) @@ -38,7 +38,7 @@ def detect_platform(): # 2) Create a Docker-based remote workspace that will set up and manage # the Docker container automatically -with DockerWorkspace( +with DockerDevWorkspace( # dynamically build agent-server image base_image="nikolaik/python-nodejs:python3.12-nodejs22", # use pre-built image for faster startup diff --git a/examples/02_remote_agent_server/03_browser_use_with_docker_sandboxed_server.py b/examples/02_remote_agent_server/03_browser_use_with_docker_sandboxed_server.py index f5fa00eba7..d88b45935d 100644 --- a/examples/02_remote_agent_server/03_browser_use_with_docker_sandboxed_server.py +++ b/examples/02_remote_agent_server/03_browser_use_with_docker_sandboxed_server.py @@ -7,7 +7,7 @@ from openhands.sdk import LLM, Conversation, get_logger from openhands.sdk.conversation.impl.remote_conversation import RemoteConversation from openhands.tools.preset.default import get_default_agent -from openhands.workspace import DockerWorkspace +from openhands.workspace import DockerDevWorkspace logger = get_logger(__name__) @@ -32,7 +32,7 @@ def detect_platform(): # Create a Docker-based remote workspace with extra ports for browser access -with DockerWorkspace( +with DockerDevWorkspace( base_image="nikolaik/python-nodejs:python3.12-nodejs22", host_port=8011, platform=detect_platform(), @@ -85,7 +85,7 @@ def event_callback(event) -> None: y = None while y != "y": y = input( - "Because you've enabled extra_ports=True in DockerWorkspace, " + "Because you've enabled extra_ports=True in DockerDevWorkspace, " "you can open a browser tab to see the *actual* browser OpenHands " "is interacting with via VNC.\n\n" "Link: http://localhost:8012/vnc.html?autoconnect=1&resize=remote\n\n" diff --git a/examples/02_remote_agent_server/04_vscode_with_docker_sandboxed_server.py b/examples/02_remote_agent_server/04_vscode_with_docker_sandboxed_server.py index 2c56f8e4e3..5f8cda51e1 100644 --- a/examples/02_remote_agent_server/04_vscode_with_docker_sandboxed_server.py +++ b/examples/02_remote_agent_server/04_vscode_with_docker_sandboxed_server.py @@ -7,7 +7,7 @@ from openhands.sdk import LLM, Conversation, get_logger from openhands.sdk.conversation.impl.remote_conversation import RemoteConversation from openhands.tools.preset.default import get_default_agent -from openhands.workspace import DockerWorkspace +from openhands.workspace import DockerDevWorkspace logger = get_logger(__name__) @@ -35,7 +35,7 @@ def detect_platform(): return "linux/amd64" -with DockerWorkspace( +with DockerDevWorkspace( base_image="nikolaik/python-nodejs:python3.12-nodejs22", host_port=18010, platform=detect_platform(), @@ -97,7 +97,7 @@ def event_callback(event) -> None: while y != "y": y = input( "\n" - "Because you've enabled extra_ports=True in DockerWorkspace, " + "Because you've enabled extra_ports=True in DockerDevWorkspace, " "you can open VSCode Web to see the workspace.\n\n" f"VSCode URL: {vscode_url}\n\n" "The VSCode should have the OpenHands settings extension installed:\n" diff --git a/openhands-agent-server/openhands/agent_server/docker/build.py b/openhands-agent-server/openhands/agent_server/docker/build.py index 074b85963f..ce9b82f987 100755 --- a/openhands-agent-server/openhands/agent_server/docker/build.py +++ b/openhands-agent-server/openhands/agent_server/docker/build.py @@ -26,18 +26,16 @@ import tomllib from contextlib import chdir from pathlib import Path -from typing import Literal from pydantic import BaseModel, Field, field_validator from openhands.sdk.logger import IN_CI, get_logger, rolling_log_view +from openhands.sdk.workspace import PlatformType, TargetType logger = get_logger(__name__) VALID_TARGETS = {"binary", "binary-minimal", "source", "source-minimal"} -TargetType = Literal["binary", "binary-minimal", "source", "source-minimal"] -PlatformType = Literal["linux/amd64", "linux/arm64"] # --- helpers --- diff --git a/openhands-agent-server/pyproject.toml b/openhands-agent-server/pyproject.toml index 042de592f0..1c39234d02 100644 --- a/openhands-agent-server/pyproject.toml +++ b/openhands-agent-server/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "alembic>=1.13", "docker>=7.1,<8", "fastapi>=0.104", + "openhands-sdk", "pydantic>=2", "sqlalchemy>=2", "uvicorn>=0.31.1", diff --git a/openhands-sdk/openhands/sdk/workspace/__init__.py b/openhands-sdk/openhands/sdk/workspace/__init__.py index 79a744e548..6760c8aa58 100644 --- a/openhands-sdk/openhands/sdk/workspace/__init__.py +++ b/openhands-sdk/openhands/sdk/workspace/__init__.py @@ -1,6 +1,6 @@ from .base import BaseWorkspace from .local import LocalWorkspace -from .models import CommandResult, FileOperationResult +from .models import CommandResult, FileOperationResult, PlatformType, TargetType from .remote import RemoteWorkspace from .workspace import Workspace @@ -10,6 +10,8 @@ "CommandResult", "FileOperationResult", "LocalWorkspace", + "PlatformType", "RemoteWorkspace", + "TargetType", "Workspace", ] diff --git a/openhands-sdk/openhands/sdk/workspace/models.py b/openhands-sdk/openhands/sdk/workspace/models.py index 3eb2f0a6c7..17b06b0f8e 100644 --- a/openhands-sdk/openhands/sdk/workspace/models.py +++ b/openhands-sdk/openhands/sdk/workspace/models.py @@ -1,8 +1,14 @@ -"""Pydantic models for workspace operation results.""" +"""Pydantic models for workspace operation results and build types.""" + +from typing import Literal from pydantic import BaseModel, Field +TargetType = Literal["binary", "binary-minimal", "source", "source-minimal"] +PlatformType = Literal["linux/amd64", "linux/arm64"] + + class CommandResult(BaseModel): """Result of executing a command in the workspace.""" diff --git a/openhands-workspace/openhands/workspace/__init__.py b/openhands-workspace/openhands/workspace/__init__.py index 35b6079234..70fd870bab 100644 --- a/openhands-workspace/openhands/workspace/__init__.py +++ b/openhands-workspace/openhands/workspace/__init__.py @@ -1,10 +1,29 @@ """OpenHands Workspace - Docker and container-based workspace implementations.""" +from typing import TYPE_CHECKING + +from openhands.sdk.workspace import PlatformType, TargetType + from .docker import DockerWorkspace from .remote_api import APIRemoteWorkspace +if TYPE_CHECKING: + from .docker import DockerDevWorkspace + __all__ = [ - "DockerWorkspace", "APIRemoteWorkspace", + "DockerDevWorkspace", + "DockerWorkspace", + "PlatformType", + "TargetType", ] + + +def __getattr__(name: str): + """Lazy import DockerDevWorkspace to avoid build module imports.""" + if name == "DockerDevWorkspace": + from .docker import DockerDevWorkspace + + return DockerDevWorkspace + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/openhands-workspace/openhands/workspace/docker/__init__.py b/openhands-workspace/openhands/workspace/docker/__init__.py index d88524a8fb..84b2194731 100644 --- a/openhands-workspace/openhands/workspace/docker/__init__.py +++ b/openhands-workspace/openhands/workspace/docker/__init__.py @@ -1,6 +1,20 @@ """Docker workspace implementation.""" +from typing import TYPE_CHECKING + from .workspace import DockerWorkspace -__all__ = ["DockerWorkspace"] +if TYPE_CHECKING: + from .dev_workspace import DockerDevWorkspace + +__all__ = ["DockerWorkspace", "DockerDevWorkspace"] + + +def __getattr__(name: str): + """Lazy import DockerDevWorkspace to avoid build module imports.""" + if name == "DockerDevWorkspace": + from .dev_workspace import DockerDevWorkspace + + return DockerDevWorkspace + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/openhands-workspace/openhands/workspace/docker/dev_workspace.py b/openhands-workspace/openhands/workspace/docker/dev_workspace.py new file mode 100644 index 0000000000..a1cd46015b --- /dev/null +++ b/openhands-workspace/openhands/workspace/docker/dev_workspace.py @@ -0,0 +1,94 @@ +"""Docker development workspace with on-the-fly image building capability.""" + +from pydantic import Field, model_validator + +from openhands.agent_server.docker.build import BuildOptions, build +from openhands.sdk.workspace import PlatformType, TargetType + +from .workspace import DockerWorkspace + + +class DockerDevWorkspace(DockerWorkspace): + """Docker workspace with on-the-fly image building capability. + + This workspace extends DockerWorkspace to support building Docker images + on-the-fly from a base image. This is useful for development and testing + scenarios where you need to customize the agent server environment. + + Note: This class requires the OpenHands SDK workspace structure and should + only be used within the OpenHands development environment or when you have + the full SDK source code available. + + For production use cases with pre-built images, use DockerWorkspace instead. + + Example: + with DockerDevWorkspace( + base_image="python:3.12", + target="source" + ) as workspace: + result = workspace.execute_command("ls -la") + """ + + # Add base_image support + base_image: str | None = Field( + default=None, + description=( + "Base Docker image to build the agent server from. " + "Mutually exclusive with server_image." + ), + ) + + # Add build-specific options + target: TargetType = Field( + default="source", description="Build target for the Docker image." + ) + + # Override platform with stricter type for building + platform: PlatformType = Field( # type: ignore[assignment] + default="linux/amd64", + description=( + "Platform for the Docker image. " + "Only linux/amd64 and linux/arm64 are supported for building." + ), + ) + + @model_validator(mode="after") + def _validate_images(self): + """Ensure exactly one of base_image or server_image is provided.""" + if (self.base_image is None) == (self.server_image is None): + raise ValueError( + "Exactly one of 'base_image' or 'server_image' must be set." + ) + if self.base_image and "ghcr.io/openhands/agent-server" in self.base_image: + raise ValueError( + "base_image cannot be a pre-built agent-server image. " + "Use server_image=... instead." + ) + return self + + def _get_image(self) -> str: + """Build the image if base_image is provided, otherwise use server_image. + + This overrides the parent method to add on-the-fly image building + capability. + + Returns: + The Docker image tag to use. + """ + if self.base_image: + # Build the image from base_image + build_opts = BuildOptions( + base_image=self.base_image, + target=self.target, + platforms=[self.platform], + push=False, + ) + tags = build(opts=build_opts) + if not tags or len(tags) == 0: + raise RuntimeError("Build failed, no image tags returned") + return tags[0] + elif self.server_image: + # Use pre-built image + return self.server_image + else: + raise ValueError("Either base_image or server_image must be set") diff --git a/openhands-workspace/openhands/workspace/docker/workspace.py b/openhands-workspace/openhands/workspace/docker/workspace.py index b5449d0456..cfd4076fa8 100644 --- a/openhands-workspace/openhands/workspace/docker/workspace.py +++ b/openhands-workspace/openhands/workspace/docker/workspace.py @@ -11,12 +11,6 @@ from pydantic import Field, PrivateAttr, model_validator -from openhands.agent_server.docker.build import ( - BuildOptions, - PlatformType, - TargetType, - build, -) from openhands.sdk.logger import get_logger from openhands.sdk.utils.command import execute_command from openhands.sdk.workspace import RemoteWorkspace @@ -59,12 +53,17 @@ def find_available_tcp_port( class DockerWorkspace(RemoteWorkspace): """Remote workspace that sets up and manages a Docker container. - This workspace creates a Docker container running the OpenHands agent server, - waits for it to become healthy, and then provides remote workspace operations - through the container's HTTP API. + This workspace creates a Docker container running a pre-built OpenHands agent + server image, waits for it to become healthy, and then provides remote workspace + operations through the container's HTTP API. + + Note: This class only works with pre-built images. To build images on-the-fly + from a base image, use DockerDevWorkspace instead. Example: - with DockerWorkspace(base_image="python:3.12") as workspace: + with DockerWorkspace( + server_image="ghcr.io/openhands/agent-server:latest" + ) as workspace: result = workspace.execute_command("ls -la") """ @@ -79,17 +78,9 @@ class DockerWorkspace(RemoteWorkspace): ) # Docker-specific configuration - base_image: str | None = Field( - default=None, - description="Base Docker image to use for the agent server container. " - "Mutually exclusive with server_image.", - ) server_image: str | None = Field( default=None, - description=( - "Pre-built agent server image to use. If None, builds from base_image." - "Mutually exclusive with base_image." - ), + description="Pre-built agent server image to use.", ) host_port: int | None = Field( default=None, @@ -106,10 +97,7 @@ class DockerWorkspace(RemoteWorkspace): detach_logs: bool = Field( default=True, description="Whether to stream Docker logs in background." ) - target: TargetType = Field( - default="source", description="Build target for the Docker image." - ) - platform: PlatformType = Field( + platform: str = Field( default="linux/amd64", description="Platform for the Docker image." ) extra_ports: bool = Field( @@ -124,19 +112,55 @@ class DockerWorkspace(RemoteWorkspace): _container_id: str | None = PrivateAttr(default=None) _logs_thread: threading.Thread | None = PrivateAttr(default=None) _stop_logs: threading.Event = PrivateAttr(default_factory=threading.Event) - _image: str = PrivateAttr() @model_validator(mode="after") - def _validate_images(self): - """Ensure exactly one of base_image or server_image is provided; cache it.""" - if (self.base_image is None) == (self.server_image is None): - raise ValueError( - "Exactly one of 'base_image' or 'server_image' must be set." - ) + def _validate_server_image(self): + """Ensure server_image is provided, unless subclass will provide it.""" + # Allow subclasses to defer server_image validation if they have base_image + # Check if this is a subclass with base_image attribute + if ( + type(self).__name__ != "DockerWorkspace" + and hasattr(self, "base_image") + and getattr(self, "base_image") is not None + ): + # This is a subclass (e.g., DockerDevWorkspace) that will provide + # server_image in model_post_init + return self + if self.server_image is None: + raise ValueError("server_image must be provided") return self def model_post_init(self, context: Any) -> None: """Set up the Docker container and initialize the remote workspace.""" + # Subclasses should call _get_image() to get the image to use + # This allows them to build or prepare the image before container startup + image = self._get_image() + self._start_container(image, context) + + def _get_image(self) -> str: + """Get the Docker image to use for the container. + + Subclasses can override this to provide custom image resolution logic + (e.g., building images on-the-fly). + + Returns: + The Docker image tag to use. + """ + if self.server_image is None: + raise ValueError("server_image must be set") + return self.server_image + + def _start_container(self, image: str, context: Any) -> None: + """Start the Docker container with the given image. + + This method handles all container lifecycle: port allocation, Docker + validation, container creation, health checks, and RemoteWorkspace + initialization. + + Args: + image: The Docker image tag to use. + context: The Pydantic context from model_post_init. + """ # Determine port if self.host_port is None: self.host_port = find_available_tcp_port() @@ -164,29 +188,6 @@ def model_post_init(self, context: Any) -> None: "Docker Desktop/daemon." ) - # Build image if needed - - if self.base_image: - if "ghcr.io/openhands/agent-server" in self.base_image: - raise RuntimeError( - "base_image cannot be a pre-built agent-server image. " - "Use server_image=... instead." - ) - build_opts = BuildOptions( - base_image=self.base_image, - target=self.target, - platforms=[self.platform], - push=False, - ) - tags = build(opts=build_opts) - assert tags and len(tags) > 0, "Build failed, no image tags returned" - self._image = tags[0] - - elif self.server_image: - self._image = self.server_image - else: - raise RuntimeError("Unreachable: one of base_image or server_image is set") - # Prepare Docker run flags flags: list[str] = [] for key in self.forward_env: @@ -227,7 +228,7 @@ def model_post_init(self, context: Any) -> None: "--name", f"agent-server-{uuid.uuid4()}", *flags, - self._image, + image, "--host", "0.0.0.0", "--port", diff --git a/tests/workspace/test_docker_workspace.py b/tests/workspace/test_docker_workspace.py index e88860bce4..0f6fd00f24 100644 --- a/tests/workspace/test_docker_workspace.py +++ b/tests/workspace/test_docker_workspace.py @@ -15,3 +15,44 @@ def test_docker_workspace_inheritance(): from openhands.workspace import DockerWorkspace assert issubclass(DockerWorkspace, RemoteWorkspace) + + +def test_docker_dev_workspace_import(): + """Test that DockerDevWorkspace can be imported from the new package.""" + from openhands.workspace import DockerDevWorkspace + + assert DockerDevWorkspace is not None + assert hasattr(DockerDevWorkspace, "__init__") + + +def test_docker_dev_workspace_inheritance(): + """Test that DockerDevWorkspace inherits from DockerWorkspace.""" + from openhands.workspace import DockerDevWorkspace, DockerWorkspace + + assert issubclass(DockerDevWorkspace, DockerWorkspace) + + +def test_docker_workspace_no_build_import(): + """ + Test that DockerWorkspace can be imported without SDK project root. + + This is the key fix for issue #1196 - importing DockerWorkspace should not + require the SDK project root since it doesn't do any image building. + """ + # This import should not raise any errors about SDK project root + from openhands.workspace import DockerWorkspace + + # Verify the class has the expected server_image field + assert "server_image" in DockerWorkspace.model_fields + # Verify the class does NOT have base_image field + assert "base_image" not in DockerWorkspace.model_fields + + +def test_docker_dev_workspace_has_build_fields(): + """Test that DockerDevWorkspace has both base_image and server_image fields.""" + from openhands.workspace import DockerDevWorkspace + + # DockerDevWorkspace should have both fields for flexibility + assert "server_image" in DockerDevWorkspace.model_fields + assert "base_image" in DockerDevWorkspace.model_fields + assert "target" in DockerDevWorkspace.model_fields diff --git a/uv.lock b/uv.lock index a8e6fc1bd5..7d18e4d8ba 100644 --- a/uv.lock +++ b/uv.lock @@ -1960,6 +1960,7 @@ dependencies = [ { name = "alembic" }, { name = "docker" }, { name = "fastapi" }, + { name = "openhands-sdk" }, { name = "pydantic" }, { name = "sqlalchemy" }, { name = "uvicorn" }, @@ -1973,6 +1974,7 @@ requires-dist = [ { name = "alembic", specifier = ">=1.13" }, { name = "docker", specifier = ">=7.1,<8" }, { name = "fastapi", specifier = ">=0.104" }, + { name = "openhands-sdk", editable = "openhands-sdk" }, { name = "pydantic", specifier = ">=2" }, { name = "sqlalchemy", specifier = ">=2" }, { name = "uvicorn", specifier = ">=0.31.1" },