diff --git a/AGENTS.md b/AGENTS.md index b3f1787a..2119f3f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,7 +146,8 @@ The dispatcher uses a **fire-and-forget** model. For each PENDING run: 4. **Wait for RUNNING** — Poll `GET /api/v1/sandboxes?id=` until status=RUNNING 5. **Upload/download tarball** — `POST /api/file/upload/` (agent-server) or `curl` inside sandbox 6. **Start entrypoint** — `POST /api/bash/start_bash_command` (agent-server) - - Extracts tarball, runs setup.sh (if present), exports env vars, runs entrypoint + - Shell path: extracts tarball, runs `setup.sh` (if present), exports env vars, runs entrypoint + - Cross-platform preset path: when `setup_script_path is null`, execution uses an inline Python runner to extract the tarball, inject env vars, and launch the entrypoint without POSIX shell features 7. **Return immediately** — Dispatcher does not wait for completion Completion is handled asynchronously: @@ -228,16 +229,17 @@ The `/v1/preset/prompt` endpoint allows creating automations by simply providing 2. Service generates SDK boilerplate code with the user's prompt 3. Creates a tarball containing: - `main.py` - SDK boilerplate that loads and executes the prompt + - `bootstrap.py` - stdlib-only cross-platform bootstrap that creates `.venv`, installs the matching OpenHands SDK packages, and re-execs `main.py` - `prompt.txt` - The user's prompt text - - `setup.sh` - SDK installation script 4. Uploads the tarball to storage (creates `TarballUpload` record) -5. Creates the `Automation` record referencing the internal upload +5. Creates the `Automation` record referencing the internal upload with `setup_script_path=None` and a bootstrap entrypoint (`python bootstrap.py` on POSIX, `py -3 bootstrap.py` on Windows) #### Files - `openhands/automation/preset_router.py` - Endpoint and tarball generation logic - `openhands/automation/presets/prompt/sdk_main.py` - SDK boilerplate that fetches LLM, secrets, and MCP config -- `openhands/automation/presets/prompt/setup.sh` - SDK installation script (installs from PyPI) +- `openhands/automation/presets/prompt/bootstrap.py` - stdlib-only cross-platform preset bootstrap +- `openhands/automation/presets/prompt/setup.sh` - legacy shell bootstrap retained for reference/tests but no longer used by generated presets #### Request Schema @@ -253,7 +255,7 @@ The `/v1/preset/prompt` endpoint allows creating automations by simply providing ### Notes - The `presets/` directory is excluded from ruff and pyright linting since it contains SDK code that runs in the sandbox, not application code -- The generated tarball uses `python main.py` as the entrypoint and `setup.sh` as the setup script +- Generated presets now launch `bootstrap.py` (`python bootstrap.py` on POSIX, `py -3 bootstrap.py` on Windows) and set `setup_script_path=None` - Future presets (e.g., plugins) can be added as additional subdirectories under `openhands/automation/presets/` ## Release Procedure diff --git a/openhands/automation/constants.py b/openhands/automation/constants.py index a7ee4317..5e968c49 100644 --- a/openhands/automation/constants.py +++ b/openhands/automation/constants.py @@ -24,12 +24,6 @@ # DO NOT CHANGE: Would break all existing automations and SDK integration. WORK_DIR = "/workspace/project" -# Path where tarballs are extracted inside the sandbox. This is: -# - Written by the sandbox initialization script -# - Read by the automation entrypoint -# DO NOT CHANGE: Would break tarball extraction in running sandboxes. -TARBALL_PATH = "/tmp/automation.tar.gz" - # model profile names mirror the agent-server profile-store constraints. MODEL_PROFILE_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" diff --git a/openhands/automation/dispatcher.py b/openhands/automation/dispatcher.py index ee3168f8..c49b1bf8 100644 --- a/openhands/automation/dispatcher.py +++ b/openhands/automation/dispatcher.py @@ -302,6 +302,7 @@ async def _fail(error: str, disable: bool = False) -> None: timeout=effective_timeout, run_id=run_id, sandbox_id=ctx.sandbox_id, + setup_script_path=automation.setup_script_path, ) except PermanentDispatchError as exc: logger.error( diff --git a/openhands/automation/execution.py b/openhands/automation/execution.py index 9c8c63cb..930ac9e0 100644 --- a/openhands/automation/execution.py +++ b/openhands/automation/execution.py @@ -5,10 +5,14 @@ """ import asyncio +import base64 import io import logging +import os import re import tarfile +import tempfile +import textwrap from typing import Any import httpx @@ -22,12 +26,19 @@ ) from openhands.automation.config import get_config -from openhands.automation.constants import TARBALL_PATH from openhands.automation.exceptions import PermanentDispatchError, TarballNotFoundError from openhands.automation.utils import log_extra from openhands.automation.utils.sandbox import delete_sandbox +# Fallback tarball destination when no per-run path is available. +# Uses the platform temp directory so the path is valid on every OS +# (Windows agent-servers reject bare POSIX /tmp/ paths). +# Not a protocol constant — the sandbox has no fixed expectation for this +# filename; it is only used by run_automation() (E2E test helper) and as a +# last-resort fallback in execute_in_context() when run_id is absent. +_FALLBACK_TARBALL_PATH = os.path.join(tempfile.gettempdir(), "automation.tar.gz") + # Default working directory for cloud/container mode DEFAULT_WORK_DIR = "/workspace/project" @@ -188,6 +199,93 @@ async def _upload( resp.raise_for_status() +def _get_python_launcher() -> str: + """Return a cross-platform Python launcher for inline runner commands. + + Preset automations run inside sandboxes where uv is guaranteed to be + present but a bare ``python`` binary may not be on PATH. ``uv run python`` + delegates interpreter resolution to uv so we never rely on a system-level + Python installation. + """ + return "uv run python" + + +def _build_python_runner_command( + entrypoint: str, + tarball_path: str, + work_dir: str, + env_vars: dict[str, str], +) -> str: + """Build a cross-platform Python runner command. + + This path avoids POSIX shell features entirely. The inline Python snippet + creates the working directory, extracts the tarball, injects environment + variables, and then launches the requested entrypoint from inside the + extracted automation directory. + """ + payload = textwrap.dedent( + f""" + import os + import pathlib + import subprocess + import tarfile + + tarball_path = {tarball_path!r} + work_dir = {work_dir!r} + entrypoint = {entrypoint!r} + env_updates = {env_vars!r} + + pathlib.Path(work_dir).mkdir(parents=True, exist_ok=True) + with tarfile.open(tarball_path, 'r:gz') as tar: + tar.extractall(work_dir) + try: + os.remove(tarball_path) + except FileNotFoundError: + pass + env = os.environ.copy() + env.update(env_updates) + result = subprocess.run(entrypoint, shell=True, cwd=work_dir, env=env) + raise SystemExit(result.returncode) + """ + ).strip() + encoded_payload = base64.b64encode(payload.encode("utf-8")).decode("ascii") + return ( + f"{_get_python_launcher()} -c " + f"\"import base64; exec(base64.b64decode('{encoded_payload}').decode())\"" + ) + + +def _build_shell_runner_command( + entrypoint: str, + tarball_path: str, + work_dir: str, + env_vars: dict[str, str], + setup_script_path: str | None, +) -> str: + """Build the legacy POSIX shell runner command.""" + exports = "" + if env_vars: + parts = [f"export {k}={_shell_quote(v)}" for k, v in env_vars.items()] + exports = " && ".join(parts) + " && " + + base_command = ( + f"mkdir -p {work_dir}" + f" && tar xzf {tarball_path} -C {work_dir}" + f" && rm -f {tarball_path}" + f" && cd {work_dir}" + ) + + if not setup_script_path: + return f"{base_command} && {exports}{entrypoint}" + + quoted_setup_path = _shell_quote(setup_script_path) + return ( + f"{base_command}" + f" && {exports}([ ! -f {quoted_setup_path} ] || bash {quoted_setup_path})" + f" && {entrypoint}" + ) + + async def _bash( client: httpx.AsyncClient, agent_url: str, @@ -339,6 +437,7 @@ async def execute_in_context( timeout: int | None = None, run_id: str | None = None, sandbox_id: str | None = None, + setup_script_path: str | None = "setup.sh", ) -> DispatchResult: """Execute automation code in an existing execution context. @@ -359,7 +458,7 @@ async def execute_in_context( env_vars: Environment variables to export timeout: Max execution time run_id: Run ID — used for logging and to derive an isolated tarball - path (/tmp/automation-.tar.gz) that prevents collisions + path (/automation-.tar.gz) that prevents collisions when concurrent runs share the same filesystem (sandboxless mode) sandbox_id: Sandbox ID for logging (Cloud mode only) @@ -377,10 +476,12 @@ def _log_ctx() -> dict[str, Any]: # Use a per-run tarball path to avoid collisions when multiple automations # run concurrently on a shared filesystem (sandboxless/local mode). # Guard against path separators in run_id before embedding it in a shell command. + # Use tempfile.gettempdir() so the path is valid on every platform (Windows + # agent-servers reject POSIX /tmp/ because it lacks a drive-letter prefix). tarball_path = ( - f"/tmp/automation-{run_id}.tar.gz" + os.path.join(tempfile.gettempdir(), f"automation-{run_id}.tar.gz") if run_id and "/" not in run_id - else TARBALL_PATH + else _FALLBACK_TARBALL_PATH ) try: @@ -394,19 +495,21 @@ def _log_ctx() -> dict[str, Any]: client, agent_url, session_key, tarball_source, tarball_path ) - exports = "" - if env_vars: - parts = [f"export {k}={_shell_quote(v)}" for k, v in env_vars.items()] - exports = " && ".join(parts) + " && " - - cmd = ( - f"mkdir -p {work_dir}" - f" && tar xzf {tarball_path} -C {work_dir}" - f" && rm -f {tarball_path}" - f" && cd {work_dir}" - f" && {exports}([ ! -f setup.sh ] || bash setup.sh)" - f" && {entrypoint}" - ) + if setup_script_path is None: + cmd = _build_python_runner_command( + entrypoint=entrypoint, + tarball_path=tarball_path, + work_dir=work_dir, + env_vars=env_vars, + ) + else: + cmd = _build_shell_runner_command( + entrypoint=entrypoint, + tarball_path=tarball_path, + work_dir=work_dir, + env_vars=env_vars, + setup_script_path=setup_script_path, + ) logger.info("Starting entrypoint: %s", entrypoint, extra=_log_ctx()) command_id = await _start_bash( @@ -455,6 +558,7 @@ async def run_automation( run_id: str | None = None, keep_sandbox: bool = False, work_dir: str = DEFAULT_WORK_DIR, + setup_script_path: str | None = "setup.sh", ) -> AutomationResult: """Execute an automation end-to-end in a fresh sandbox (blocking). @@ -526,26 +630,37 @@ def _log_ctx() -> dict[str, Any]: if isinstance(tarball_source, bytes): logger.info("Uploading tarball to sandbox", extra=_log_ctx()) await _upload( - client, agent_url, session_key, tarball_source, TARBALL_PATH + client, + agent_url, + session_key, + tarball_source, + _FALLBACK_TARBALL_PATH, ) else: logger.info("Downloading tarball in sandbox from URL", extra=_log_ctx()) await _download_in_sandbox( - client, agent_url, session_key, tarball_source, TARBALL_PATH + client, + agent_url, + session_key, + tarball_source, + _FALLBACK_TARBALL_PATH, ) - exports = "" - if env_vars: - parts = [f"export {k}={_shell_quote(v)}" for k, v in env_vars.items()] - exports = " && ".join(parts) + " && " - - cmd = ( - f"mkdir -p {work_dir}" - f" && tar xzf {TARBALL_PATH} -C {work_dir}" - f" && cd {work_dir}" - f" && {exports}([ ! -f setup.sh ] || bash setup.sh)" - f" && {entrypoint}" - ) + if setup_script_path is None: + cmd = _build_python_runner_command( + entrypoint=entrypoint, + tarball_path=_FALLBACK_TARBALL_PATH, + work_dir=work_dir, + env_vars=env_vars, + ) + else: + cmd = _build_shell_runner_command( + entrypoint=entrypoint, + tarball_path=_FALLBACK_TARBALL_PATH, + work_dir=work_dir, + env_vars=env_vars, + setup_script_path=setup_script_path, + ) logger.info("Executing entrypoint: %s", entrypoint, extra=_log_ctx()) exit_code, stdout, stderr = await _bash( diff --git a/openhands/automation/preset_router.py b/openhands/automation/preset_router.py index 48b8d373..bb938035 100644 --- a/openhands/automation/preset_router.py +++ b/openhands/automation/preset_router.py @@ -12,7 +12,6 @@ import io import json import logging -import os import tarfile import uuid from collections.abc import AsyncIterator @@ -48,17 +47,21 @@ PRESETS_DIR = Path(__file__).parent / "presets" PROMPT_PRESET_DIR = PRESETS_DIR / "prompt" PLUGIN_PRESET_DIR = PRESETS_DIR / "plugin" +BOOTSTRAP_PATH = PRESETS_DIR / "bootstrap.py" +# uv is guaranteed to be present in all preset sandboxes; using ``uv run +# python`` avoids assuming a system-level Python installation is available. +PRESET_BOOTSTRAP_ENTRYPOINT = "uv run python bootstrap.py" def _get_preset_entrypoint() -> str: - """Return the preset entrypoint for the current host platform. + """Return the preset entrypoint. - Preset automations create their virtual environment inside the run working - directory. Cloud sandboxes use the POSIX layout (``.venv/bin/python``), - while native Windows uses ``.venv/Scripts/python.exe``. + Prompt and plugin presets launch a Python bootstrap script that creates + the virtual environment and re-execs ``main.py`` from inside it. We use + ``uv run python`` because uv is guaranteed to be present in the sandbox + whereas a bare ``python`` binary may not be on PATH. """ - python_path = ".venv/Scripts/python.exe" if os.name == "nt" else ".venv/bin/python" - return f"{python_path} main.py" + return PRESET_BOOTSTRAP_ENTRYPOINT # Preset file caches to avoid I/O on every request @@ -75,7 +78,7 @@ def _load_prompt_preset_files() -> dict[str, str]: if _PROMPT_PRESET_CACHE is None: _PROMPT_PRESET_CACHE = { "main.py": (PROMPT_PRESET_DIR / "sdk_main.py").read_text(), - "setup.sh": (PROMPT_PRESET_DIR / "setup.sh").read_text(), + "bootstrap.py": BOOTSTRAP_PATH.read_text(), } return _PROMPT_PRESET_CACHE @@ -89,7 +92,7 @@ def _load_plugin_preset_files() -> dict[str, str]: if _PLUGIN_PRESET_CACHE is None: _PLUGIN_PRESET_CACHE = { "main.py": (PLUGIN_PRESET_DIR / "sdk_main.py").read_text(), - "setup.sh": (PLUGIN_PRESET_DIR / "setup.sh").read_text(), + "bootstrap.py": BOOTSTRAP_PATH.read_text(), } return _PLUGIN_PRESET_CACHE @@ -174,8 +177,8 @@ def _generate_tarball(prompt: str, repos: list[RepoSource] | None = None) -> byt The tarball contains: - main.py: SDK boilerplate that loads and executes the prompt + - bootstrap.py: Cross-platform bootstrap that installs SDK packages - prompt.txt: The user's prompt text - - setup.sh: Script to install the SDK - repos_config.json: (optional) Repository configuration for cloning Note: Clone and skill loading functionality is now provided by the SDK's @@ -194,8 +197,8 @@ def _generate_tarball(prompt: str, repos: list[RepoSource] | None = None) -> byt with tarfile.open(fileobj=tarball_buffer, mode="w:gz") as tar: _add_file_to_tar(tar, "main.py", preset_files["main.py"]) + _add_file_to_tar(tar, "bootstrap.py", preset_files["bootstrap.py"]) _add_file_to_tar(tar, "prompt.txt", prompt) - _add_file_to_tar(tar, "setup.sh", preset_files["setup.sh"], mode=0o755) # Add repos config if repos specified (SDK workspace handles cloning) if repos: @@ -440,7 +443,7 @@ async def create_automation_from_prompt( model=model, trigger=body.trigger.model_dump(), tarball_path=tarball_path, - setup_script_path="setup.sh", + setup_script_path=None, entrypoint=_get_preset_entrypoint(), timeout=body.timeout, ) @@ -653,8 +656,8 @@ def _generate_plugin_tarball( with tarfile.open(fileobj=tarball_buffer, mode="w:gz") as tar: _add_file_to_tar(tar, "main.py", preset_files["main.py"]) + _add_file_to_tar(tar, "bootstrap.py", preset_files["bootstrap.py"]) _add_file_to_tar(tar, "prompt.txt", prompt) - _add_file_to_tar(tar, "setup.sh", preset_files["setup.sh"], mode=0o755) if variants is not None: experiment_config = { @@ -799,7 +802,7 @@ async def create_automation_from_plugin( model=model, trigger=body.trigger.model_dump(), tarball_path=tarball_path, - setup_script_path="setup.sh", + setup_script_path=None, entrypoint=_get_preset_entrypoint(), timeout=body.timeout, ) diff --git a/openhands/automation/presets/bootstrap.py b/openhands/automation/presets/bootstrap.py new file mode 100644 index 00000000..cc27c52a --- /dev/null +++ b/openhands/automation/presets/bootstrap.py @@ -0,0 +1,96 @@ +"""Cross-platform preset bootstrap shared by all preset types. + +This script runs before the generated main.py and uses only the Python standard +library. It creates the per-run virtual environment, installs the matching +OpenHands SDK packages, then re-execs main.py with the venv interpreter. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path +from urllib.request import Request, urlopen + +SDK_PACKAGES = ( + "openhands-sdk", + "openhands-tools", + "openhands-workspace", +) +SCRIPT_DIR = Path(__file__).resolve().parent +VENV_DIR = SCRIPT_DIR / ".venv" +MAIN_PATH = SCRIPT_DIR / "main.py" +PYTHON_REQUIREMENT = ">=3.12" +SDK_VERSION_ENV_VAR = "OPENHANDS_SDK_VERSION" +AUTOMATION_API_URL_ENV_VAR = "AUTOMATION_API_URL" +SDK_VERSION_PATH = "/sdk-version" + + +def _require_uv() -> str: + uv_path = shutil.which("uv") + if not uv_path: + raise SystemExit("[bootstrap] ERROR: uv is required but was not found in PATH") + return uv_path + + +def _fetch_sdk_version() -> str: + if version := os.environ.get(SDK_VERSION_ENV_VAR): + return version + + api_url = os.environ.get(AUTOMATION_API_URL_ENV_VAR, "").rstrip("/") + if not api_url: + raise SystemExit( + f"[bootstrap] ERROR: {AUTOMATION_API_URL_ENV_VAR} is required to fetch the SDK version" + ) + + request = Request( + f"{api_url}{SDK_VERSION_PATH}", + headers={"Accept": "application/json"}, + ) + with urlopen(request) as response: + payload = json.load(response) + + version = payload.get("version") + if not version: + raise SystemExit("[bootstrap] ERROR: sdk-version response did not include a version") + return version + + +def _run_checked(*args: str) -> None: + print("[bootstrap] running:", " ".join(args)) + subprocess.run(args, check=True) + + +def _venv_python_path() -> Path: + if os.name == "nt": + return VENV_DIR / "Scripts" / "python.exe" + return VENV_DIR / "bin" / "python" + + +def main() -> None: + uv_path = _require_uv() + sdk_version = _fetch_sdk_version() + print(f"[bootstrap] Creating virtual environment in {VENV_DIR}") + _run_checked(uv_path, "venv", str(VENV_DIR), "--python", PYTHON_REQUIREMENT, "--quiet") + + print(f"[bootstrap] Installing OpenHands SDK packages at version {sdk_version}") + install_args = [uv_path, "pip", "install", "--python", str(_venv_python_path()), "--quiet"] + install_args.extend(f"{package}=={sdk_version}" for package in SDK_PACKAGES) + _run_checked(*install_args) + + venv_python = _venv_python_path() + if not venv_python.exists(): + raise SystemExit( + f"[bootstrap] ERROR: Expected virtualenv Python at {venv_python}, but it was not created" + ) + if not MAIN_PATH.exists(): + raise SystemExit(f"[bootstrap] ERROR: Expected generated main.py at {MAIN_PATH}") + + print(f"[bootstrap] Launching generated automation with {venv_python}") + os.execv(str(venv_python), [str(venv_python), str(MAIN_PATH)]) + + +if __name__ == "__main__": + main() diff --git a/tests/test_ab_testing_integration.py b/tests/test_ab_testing_integration.py index cbed7cae..9b4f3c36 100644 --- a/tests/test_ab_testing_integration.py +++ b/tests/test_ab_testing_integration.py @@ -238,8 +238,8 @@ async def test_tarball_contains_experiment_config(self, client, mock_file_store) assert "experiment_config.json" in files assert "plugins_config.json" not in files assert "main.py" in files + assert "bootstrap.py" in files assert "prompt.txt" in files - assert "setup.sh" in files async def test_experiment_config_matches_request(self, client, mock_file_store): """experiment_config.json faithfully represents the request.""" diff --git a/tests/test_disable_automation.py b/tests/test_disable_automation.py index 886498ab..0f71e2e2 100644 --- a/tests/test_disable_automation.py +++ b/tests/test_disable_automation.py @@ -49,7 +49,11 @@ def _docker_available() -> bool: try: import socket - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + af_unix = getattr(socket, "AF_UNIX", None) + if af_unix is None: + return False + + sock = socket.socket(af_unix, socket.SOCK_STREAM) sock.connect("/var/run/docker.sock") sock.close() return True diff --git a/tests/test_execution.py b/tests/test_execution.py index de5ef2eb..f4968ff6 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -4,16 +4,20 @@ (run_automation against a real sandbox) lives in scripts/test_automation.py. """ +import base64 import io +import os +import re import tarfile +import tempfile from unittest.mock import AsyncMock, MagicMock, patch import pytest from openhands.automation.config import get_config -from openhands.automation.constants import TARBALL_PATH from openhands.automation.exceptions import PermanentDispatchError, TarballNotFoundError from openhands.automation.execution import ( + _FALLBACK_TARBALL_PATH as TARBALL_PATH, DEFAULT_WORK_DIR, AutomationResult, DispatchResult, @@ -164,12 +168,13 @@ async def test_upload_uses_query_param_for_path(self): mock_client = AsyncMock() mock_client.post = AsyncMock(return_value=mock_response) + dest = os.path.join(tempfile.gettempdir(), "automation.tar.gz") await _upload( client=mock_client, agent_url="https://agent.example.com", session_key="test-session-key", data=b"test data", - dest="/tmp/automation.tar.gz", + dest=dest, ) # Verify post was called with query param, not path param @@ -179,14 +184,14 @@ async def test_upload_uses_query_param_for_path(self): url = call_args[0][0] # URL should use query param format assert "?path=" in url, f"Expected query param in URL, got: {url}" - assert "/tmp/automation.tar.gz" not in url.split("?")[0], ( + assert dest not in url.split("?")[0], ( f"Path should not be in URL path segment: {url}" ) # Verify the path is properly encoded in query string - assert ( - "path=%2Ftmp%2Fautomation.tar.gz" in url - or "path=/tmp/automation.tar.gz" in url - ) + from urllib.parse import quote + + encoded = quote(dest, safe="") + assert f"path={encoded}" in url or f"path={dest}" in url @pytest.mark.asyncio async def test_upload_preserves_absolute_path(self): @@ -300,6 +305,45 @@ async def test_success_returns_dispatch_result(self, mock_start_bash, mock_uploa assert result.success is True assert result.sandbox_id == "test-sandbox-id" + @pytest.mark.asyncio + @patch("openhands.automation.execution._upload") + @patch("openhands.automation.execution._start_bash") + async def test_setup_script_none_uses_python_runner( + self, mock_start_bash, mock_upload + ): + """setup_script_path=None should bypass POSIX setup.sh handling.""" + mock_upload.return_value = None + mock_start_bash.return_value = "cmd-bootstrap" + run_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + expected_path = os.path.join( + tempfile.gettempdir(), f"automation-{run_id}.tar.gz" + ) + + await execute_in_context( + client=AsyncMock(), + agent_url="https://agent.example.com", + session_key="key", + entrypoint="python bootstrap.py", + tarball_source=b"fake bytes", + work_dir=DEFAULT_WORK_DIR, + env_vars={"AUTOMATION_API_URL": "https://automation.example.com"}, + run_id=run_id, + setup_script_path=None, + ) + + bash_cmd = mock_start_bash.call_args.args[3] + assert "base64.b64decode" in bash_cmd + assert "export " not in bash_cmd + assert "setup.sh" not in bash_cmd + + match = re.search(r"base64\.b64decode\('([^']+)'\)", bash_cmd) + assert match is not None + payload = base64.b64decode(match.group(1)).decode("utf-8") + assert repr(expected_path) in payload + assert DEFAULT_WORK_DIR in payload + assert "python bootstrap.py" in payload + assert "AUTOMATION_API_URL" in payload + class TestPerRunTarballPath: """Tests that execute_in_context uses an isolated per-run tarball path. @@ -334,7 +378,8 @@ async def test_bytes_upload_uses_per_run_path(self, mock_start_bash, mock_upload ) uploaded_dest = mock_upload.call_args.args[4] # (client, url, key, data, dest) - assert uploaded_dest == f"/tmp/automation-{run_id}.tar.gz" + expected = os.path.join(tempfile.gettempdir(), f"automation-{run_id}.tar.gz") + assert uploaded_dest == expected assert uploaded_dest != TARBALL_PATH @pytest.mark.asyncio @@ -359,7 +404,8 @@ async def test_url_download_uses_per_run_path( ) download_dest = mock_download_in_sandbox.call_args.args[4] - assert download_dest == f"/tmp/automation-{run_id}.tar.gz" + expected = os.path.join(tempfile.gettempdir(), f"automation-{run_id}.tar.gz") + assert download_dest == expected assert download_dest != TARBALL_PATH @pytest.mark.asyncio @@ -372,7 +418,9 @@ async def test_bash_cmd_uses_per_run_path_and_cleans_up( mock_upload.return_value = None mock_start_bash.return_value = "cmd-abc" run_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" - expected_path = f"/tmp/automation-{run_id}.tar.gz" + expected_path = os.path.join( + tempfile.gettempdir(), f"automation-{run_id}.tar.gz" + ) await execute_in_context( client=AsyncMock(), @@ -456,8 +504,14 @@ async def test_concurrent_runs_use_distinct_paths( ) upload_dests = {c.args[4] for c in mock_upload.call_args_list} - assert f"/tmp/automation-{run_id_a}.tar.gz" in upload_dests - assert f"/tmp/automation-{run_id_b}.tar.gz" in upload_dests + expected_a = os.path.join( + tempfile.gettempdir(), f"automation-{run_id_a}.tar.gz" + ) + expected_b = os.path.join( + tempfile.gettempdir(), f"automation-{run_id_b}.tar.gz" + ) + assert expected_a in upload_dests + assert expected_b in upload_dests assert len(upload_dests) == 2, "Each run must upload to its own unique path" @pytest.mark.asyncio diff --git a/tests/test_preset_router.py b/tests/test_preset_router.py index c29ee2cf..189ab4d7 100644 --- a/tests/test_preset_router.py +++ b/tests/test_preset_router.py @@ -32,8 +32,12 @@ def _docker_available() -> bool: """Check if Docker is available for testcontainers.""" + af_unix = getattr(socket, "AF_UNIX", None) + if af_unix is None: + return False + try: - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock = socket.socket(af_unix, socket.SOCK_STREAM) sock.connect("/var/run/docker.sock") sock.close() return True @@ -115,27 +119,24 @@ def test_plugin_setup_sh_fetches_sdk_version_from_api(self): class TestPresetEntrypoint: - def test_get_preset_entrypoint_posix(self, monkeypatch): - monkeypatch.setattr("openhands.automation.preset_router.os.name", "posix") - assert _get_preset_entrypoint() == ".venv/bin/python main.py" - - def test_get_preset_entrypoint_windows(self, monkeypatch): - monkeypatch.setattr("openhands.automation.preset_router.os.name", "nt") - assert _get_preset_entrypoint() == ".venv/Scripts/python.exe main.py" - - def test_prompt_setup_sh_falls_back_when_python3_missing(self): - setup_sh_path = PRESETS_DIR / "prompt" / "setup.sh" - content = setup_sh_path.read_text() - assert "command -v python3" in content - assert "command -v python" in content - assert "command -v py" in content - - def test_plugin_setup_sh_falls_back_when_python3_missing(self): - setup_sh_path = PRESETS_DIR / "plugin" / "setup.sh" - content = setup_sh_path.read_text() - assert "command -v python3" in content - assert "command -v python" in content - assert "command -v py" in content + def test_get_preset_entrypoint_uses_uv(self): + # uv is guaranteed in all preset sandboxes, so we never rely on a + # bare system-level Python binary. + assert _get_preset_entrypoint() == "uv run python bootstrap.py" + + def test_prompt_bootstrap_contains_cross_platform_venv_paths(self): + bootstrap_path = PRESETS_DIR / "bootstrap.py" + content = bootstrap_path.read_text() + assert 'VENV_DIR / "Scripts" / "python.exe"' in content + assert 'VENV_DIR / "bin" / "python"' in content + assert "AUTOMATION_API_URL" in content + + def test_plugin_bootstrap_contains_cross_platform_venv_paths(self): + bootstrap_path = PRESETS_DIR / "bootstrap.py" + content = bootstrap_path.read_text() + assert 'VENV_DIR / "Scripts" / "python.exe"' in content + assert 'VENV_DIR / "bin" / "python"' in content + assert "AUTOMATION_API_URL" in content class TestGenerateTarball: @@ -150,8 +151,8 @@ def test_generate_tarball_structure(self): with tarfile.open(fileobj=io.BytesIO(tarball_bytes), mode="r:gz") as tar: names = tar.getnames() assert "main.py" in names + assert "bootstrap.py" in names assert "prompt.txt" in names - assert "setup.sh" in names # Note: load_skills.py and clone_repos.py are no longer needed # as the SDK workspace now provides these methods directly @@ -191,15 +192,18 @@ def test_generate_tarball_main_py_content(self): assert "model_copy" in main_content assert "prompt.txt" in main_content - def test_generate_tarball_setup_sh_executable(self): - """setup.sh in tarball has executable permissions.""" + def test_generate_tarball_bootstrap_content(self): + """bootstrap.py in tarball contains the cross-platform SDK bootstrap.""" prompt = "Test prompt" tarball_bytes = _generate_tarball(prompt) with tarfile.open(fileobj=io.BytesIO(tarball_bytes), mode="r:gz") as tar: - setup_info = tar.getmember("setup.sh") - # Check executable bit is set (0o755 includes 0o100 for owner execute) - assert setup_info.mode & 0o100 + bootstrap_file = tar.extractfile("bootstrap.py") + assert bootstrap_file is not None + bootstrap_content = bootstrap_file.read().decode("utf-8") + assert "OPENHANDS_SDK_VERSION" in bootstrap_content + assert "uv" in bootstrap_content + assert "os.execv" in bootstrap_content def test_generate_tarball_without_repos(self): """Generated tarball without repos does not include repos_config.json.""" @@ -242,8 +246,8 @@ class TestReplacePromptInTarball: def test_replaces_prompt_and_preserves_sibling_files(self): """The prompt is swapped while every other file is left byte-for-byte intact.""" - # Arrange — a plugin preset tarball carries main.py, setup.sh, prompt.txt, - # plugins_config.json and repos_config.json; all but the prompt must survive. + # Arrange — a plugin preset tarball carries main.py, bootstrap.py, + # prompt.txt, plugins_config.json and repos_config.json. original = _generate_plugin_tarball( [PluginSource(source="github:owner/repo")], "Original prompt", @@ -265,15 +269,19 @@ def _read(tarball_bytes): extracted = tar.extractfile(member) assert extracted is not None files[member.name] = extracted.read() - return files, tar.getmember("setup.sh").mode + return files - old_files, _ = _read(original) - new_files, new_setup_mode = _read(updated) + old_files = _read(original) + new_files = _read(updated) assert new_files["prompt.txt"].decode() == "New prompt" - for name in ("main.py", "setup.sh", "plugins_config.json", "repos_config.json"): + for name in ( + "main.py", + "bootstrap.py", + "plugins_config.json", + "repos_config.json", + ): assert new_files[name] == old_files[name] - assert new_setup_mode & 0o100 # setup.sh stays executable def test_returns_none_when_tarball_has_no_prompt(self): """A tarball without prompt.txt is not regenerable, so None is returned.""" @@ -454,7 +462,7 @@ async def test_create_from_prompt_success( assert data["trigger"]["type"] == "cron" assert data["trigger"]["schedule"] == "0 9 * * 1" assert data["entrypoint"] == _get_preset_entrypoint() - assert data["setup_script_path"] == "setup.sh" + assert data["setup_script_path"] is None assert data["tarball_path"].startswith("oh-internal://uploads/") assert data["enabled"] is True assert "id" in data @@ -470,8 +478,8 @@ async def test_create_from_prompt_success( assert tarball_bytes is not None with tarfile.open(fileobj=io.BytesIO(tarball_bytes), mode="r:gz") as tar: assert "main.py" in tar.getnames() + assert "bootstrap.py" in tar.getnames() assert "prompt.txt" in tar.getnames() - assert "setup.sh" in tar.getnames() assert "automation_model.py" not in tar.getnames() # Verify prompt content matches what was sent @@ -581,7 +589,7 @@ async def test_create_from_prompt_creates_automation_record( assert automation.name == "Automation Record Test" assert automation.prompt == "Print hello" assert automation.entrypoint == _get_preset_entrypoint() - assert automation.setup_script_path == "setup.sh" + assert automation.setup_script_path is None assert automation.timeout == 300 assert automation.user_id == TEST_USER_ID assert automation.org_id == TEST_ORG_ID @@ -803,9 +811,9 @@ def test_generate_plugin_tarball_structure(self): with tarfile.open(fileobj=io.BytesIO(tarball_bytes), mode="r:gz") as tar: names = tar.getnames() assert "main.py" in names + assert "bootstrap.py" in names assert "plugins_config.json" in names assert "prompt.txt" in names - assert "setup.sh" in names def test_generate_plugin_tarball_plugins_config(self): """Generated tarball contains correct plugins_config.json.""" @@ -865,16 +873,19 @@ def test_generate_plugin_tarball_main_py_content(self): assert "PluginSource.model_validate" in main_content assert "plugins=plugin_sources" in main_content - def test_generate_plugin_tarball_setup_sh_executable(self): - """setup.sh in plugin tarball has executable permissions.""" + def test_generate_plugin_tarball_bootstrap_content(self): + """bootstrap.py in plugin tarball contains the cross-platform bootstrap.""" plugins = [PluginSource(source="github:owner/repo")] prompt = "Test prompt" tarball_bytes = _generate_plugin_tarball(plugins, prompt) with tarfile.open(fileobj=io.BytesIO(tarball_bytes), mode="r:gz") as tar: - setup_info = tar.getmember("setup.sh") - # Check executable bit is set (0o755 includes 0o100 for owner execute) - assert setup_info.mode & 0o100 + bootstrap_file = tar.extractfile("bootstrap.py") + assert bootstrap_file is not None + bootstrap_content = bootstrap_file.read().decode("utf-8") + assert "OPENHANDS_SDK_VERSION" in bootstrap_content + assert "uv" in bootstrap_content + assert "os.execv" in bootstrap_content def test_generate_plugin_tarball_excludes_none_values(self): """Generated plugins_config.json excludes None values.""" @@ -1251,8 +1262,8 @@ def test_experiment_tarball_contains_experiment_config(self): assert "experiment_config.json" in names assert "plugins_config.json" not in names assert "main.py" in names + assert "bootstrap.py" in names assert "prompt.txt" in names - assert "setup.sh" in names def test_experiment_config_content(self): """experiment_config.json has correct structure.""" @@ -1373,7 +1384,7 @@ async def test_create_from_plugin_success( assert data["trigger"]["type"] == "cron" assert data["trigger"]["schedule"] == "0 9 * * 1" assert data["entrypoint"] == _get_preset_entrypoint() - assert data["setup_script_path"] == "setup.sh" + assert data["setup_script_path"] is None assert data["tarball_path"].startswith("oh-internal://uploads/") assert data["enabled"] is True assert "id" in data @@ -1389,9 +1400,9 @@ async def test_create_from_plugin_success( assert tarball_bytes is not None with tarfile.open(fileobj=io.BytesIO(tarball_bytes), mode="r:gz") as tar: assert "main.py" in tar.getnames() + assert "bootstrap.py" in tar.getnames() assert "plugins_config.json" in tar.getnames() assert "prompt.txt" in tar.getnames() - assert "setup.sh" in tar.getnames() # Verify plugins config config_file = tar.extractfile("plugins_config.json") @@ -1489,7 +1500,7 @@ async def test_create_from_plugin_creates_automation_record( assert automation.name == "Automation Record Test" assert automation.prompt == "Run plugin tasks" assert automation.entrypoint == _get_preset_entrypoint() - assert automation.setup_script_path == "setup.sh" + assert automation.setup_script_path is None assert automation.timeout == 300 assert automation.user_id == TEST_USER_ID assert automation.org_id == TEST_ORG_ID