Skip to content
Open
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
12 changes: 7 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<id>` until status=RUNNING
5. **Upload/download tarball** — `POST /api/file/upload/<path>` (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:
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
6 changes: 0 additions & 6 deletions openhands/automation/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}$"

Expand Down
1 change: 1 addition & 0 deletions openhands/automation/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
177 changes: 146 additions & 31 deletions openhands/automation/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"

Expand Down Expand Up @@ -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(
Comment thread
jamiechicago312 marked this conversation as resolved.
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,
Expand Down Expand Up @@ -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.

Expand All @@ -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-<run_id>.tar.gz) that prevents collisions
path (<tempdir>/automation-<run_id>.tar.gz) that prevents collisions
when concurrent runs share the same filesystem (sandboxless mode)
sandbox_id: Sandbox ID for logging (Cloud mode only)

Expand All @@ -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:
Expand All @@ -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(
Expand Down Expand Up @@ -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).

Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading