Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
13 changes: 8 additions & 5 deletions openhands/automation/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
- Sandbox conventions (if expected by SDK/runtime)
"""

import os
import tempfile


# ---------------------------------------------------------------------------
# Sandbox protocol conventions
# ---------------------------------------------------------------------------
Expand All @@ -24,11 +28,10 @@
# 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"
# Path where tarballs are stored before extraction. Uses the platform temp
# directory so the agent-server accepts the path on every OS (Windows
# requires a drive-letter prefix for os.path.isabs() to return True).
TARBALL_PATH = os.path.join(tempfile.gettempdir(), "automation.tar.gz")
Comment thread
jamiechicago312 marked this conversation as resolved.
Outdated

# 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
148 changes: 121 additions & 27 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 Down Expand Up @@ -188,6 +192,87 @@ async def _upload(
resp.raise_for_status()


def _get_python_launcher() -> str:
"""Return a cross-platform Python launcher for inline runner commands."""
return "py -3" if os.name == "nt" else "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 +424,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 +445,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,8 +463,10 @@ 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
)
Expand All @@ -394,19 +482,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 +545,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 @@ -534,18 +625,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" && 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("Executing entrypoint: %s", entrypoint, extra=_log_ctx())
exit_code, stdout, stderr = await _bash(
Expand Down
24 changes: 12 additions & 12 deletions openhands/automation/preset_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,17 @@
PRESETS_DIR = Path(__file__).parent / "presets"
PROMPT_PRESET_DIR = PRESETS_DIR / "prompt"
PLUGIN_PRESET_DIR = PRESETS_DIR / "plugin"
PRESET_BOOTSTRAP_ENTRYPOINT = "python bootstrap.py"


def _get_preset_entrypoint() -> str:
"""Return the preset entrypoint for the current host platform.

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 now launch a Python bootstrap script that creates
the virtual environment and re-execs ``main.py`` from inside it. Native
Windows prefers the ``py`` launcher; other platforms use ``python``.
"""
python_path = ".venv/Scripts/python.exe" if os.name == "nt" else ".venv/bin/python"
return f"{python_path} main.py"
return "py -3 bootstrap.py" if os.name == "nt" else PRESET_BOOTSTRAP_ENTRYPOINT


# Preset file caches to avoid I/O on every request
Expand All @@ -75,7 +75,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": (PROMPT_PRESET_DIR / "bootstrap.py").read_text(),
}
return _PROMPT_PRESET_CACHE

Expand All @@ -89,7 +89,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": (PLUGIN_PRESET_DIR / "bootstrap.py").read_text(),
}
return _PLUGIN_PRESET_CACHE

Expand Down Expand Up @@ -174,8 +174,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
Expand All @@ -194,8 +194,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:
Expand Down Expand Up @@ -440,7 +440,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,
)
Expand Down Expand Up @@ -653,8 +653,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 = {
Expand Down Expand Up @@ -799,7 +799,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,
)
Expand Down
Loading
Loading