Compare commits

..

2 Commits

Author SHA1 Message Date
openhands 9314052e89 fix(enterprise): Update GitLab resolver tests for new background_tasks parameter
Update existing GitLab resolver tests to pass the new background_tasks
parameter after adding event forwarding support to gitlab_events endpoint.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-24 18:29:08 +00:00
openhands 08890f189f feat(enterprise): Add GitLab event forwarding to automation service
Add support for forwarding GitLab webhook events to the automation service,
similar to the existing GitHub implementation. This enables automation triggers
for GitLab repositories.

Changes:
- Add GitLab support to _extract_owner_info() in AutomationEventService
- Handle GitLab-specific payload structure (project/namespace vs repository/owner)
- Support lowercase 'user' owner_type for GitLab personal org fallback
- Add event forwarding to GitLab route handler with BackgroundTasks
- Add comprehensive unit tests for GitLab event forwarding

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-24 17:36:15 +00:00
181 changed files with 34927 additions and 1594 deletions
@@ -46,12 +46,34 @@ These files contain image tags that **must** be updated whenever the SDK version
### `openhands/version.py`
- Reads version from `pyproject.toml` at runtime → `openhands.__version__`
### `openhands/resolver/issue_resolver.py`
- Builds `ghcr.io/openhands/runtime:{openhands.__version__}-nikolaik` dynamically
### `openhands/runtime/utils/runtime_build.py`
- Base repo URL `ghcr.io/openhands/runtime` is a constant; version comes from elsewhere
### `.github/scripts/update_pr_description.sh`
- Uses `${SHORT_SHA}` variable at CI runtime, not hardcoded
### `enterprise/Dockerfile`
- `ARG BASE="ghcr.io/openhands/openhands"` — base image, version supplied at build time
## V0 Legacy Files (separate update cadence)
These reference the V0 runtime image (`ghcr.io/openhands/runtime:X.Y-nikolaik`) for local Docker/Kubernetes paths. They are **not** updated as part of a V1 release but may be updated independently.
### `Development.md`
- `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:X.Y-nikolaik`
### `openhands/runtime/impl/kubernetes/README.md`
- `runtime_container_image = "docker.openhands.dev/openhands/runtime:X.Y-nikolaik"`
### `enterprise/enterprise_local/README.md`
- Uses `ghcr.io/openhands/runtime:main-nikolaik` (points to `main`, not versioned)
### `third_party/runtime/impl/daytona/README.md`
- Uses `${OPENHANDS_VERSION}` variable, not hardcoded
## Image Registries
| Registry | Usage |
+228
View File
@@ -0,0 +1,228 @@
name: End-to-End Tests
on:
pull_request:
types: [opened, synchronize, reopened, labeled]
branches:
- main
- develop
workflow_dispatch:
jobs:
e2e-tests:
if: contains(github.event.pull_request.labels.*.name, 'end-to-end') || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 60
env:
GITHUB_REPO_NAME: ${{ github.repository }}
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install poetry via pipx
uses: abatilo/actions-poetry@v4
with:
poetry-version: 2.1.3
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: 'poetry'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-0 libnotify4 libnss3 libxss1 libxtst6 xauth xvfb libgbm1 libasound2t64 netcat-openbsd
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: 'frontend/package-lock.json'
- name: Setup environment for end-to-end tests
run: |
# Create test results directory
mkdir -p test-results
# Create downloads directory for OpenHands (use a directory in the home folder)
mkdir -p $HOME/downloads
sudo chown -R $USER:$USER $HOME/downloads
sudo chmod -R 755 $HOME/downloads
- name: Build OpenHands
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
LLM_MODEL: ${{ secrets.LLM_MODEL || 'gpt-4o' }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY || 'test-key' }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
INSTALL_DOCKER: 1
RUNTIME: docker
FRONTEND_PORT: 12000
FRONTEND_HOST: 0.0.0.0
BACKEND_HOST: 0.0.0.0
BACKEND_PORT: 3000
ENABLE_BROWSER: true
INSTALL_PLAYWRIGHT: 1
run: |
# Fix poetry.lock file if needed
echo "Fixing poetry.lock file if needed..."
poetry lock
# Build OpenHands using make build
echo "Running make build..."
make build
# Install Chromium Headless Shell for Playwright (needed for pytest-playwright)
echo "Installing Chromium Headless Shell for Playwright..."
poetry run playwright install chromium-headless-shell
# Verify Playwright browsers are installed (for e2e tests only)
echo "Verifying Playwright browsers installation for e2e tests..."
BROWSER_CHECK=$(poetry run python tests/e2e/check_playwright.py 2>/dev/null)
if [ "$BROWSER_CHECK" != "chromium_found" ]; then
echo "ERROR: Chromium browser not found or not working for e2e tests"
echo "$BROWSER_CHECK"
exit 1
else
echo "Playwright browsers are properly installed for e2e tests."
fi
# Docker runtime will handle workspace directory creation
# Start the application using make run with custom parameters and reduced logging
echo "Starting OpenHands using make run..."
# Set environment variables to reduce logging verbosity
export PYTHONUNBUFFERED=1
export LOG_LEVEL=WARNING
export UVICORN_LOG_LEVEL=warning
export OPENHANDS_LOG_LEVEL=WARNING
FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0 make run > /tmp/openhands-e2e-test.log 2>&1 &
# Store the PID of the make run process
MAKE_PID=$!
echo "OpenHands started with PID: $MAKE_PID"
# Wait for the application to start
echo "Waiting for OpenHands to start..."
max_attempts=15
attempt=1
while [ $attempt -le $max_attempts ]; do
echo "Checking if OpenHands is running (attempt $attempt of $max_attempts)..."
# Check if the process is still running
if ! ps -p $MAKE_PID > /dev/null; then
echo "ERROR: OpenHands process has terminated unexpectedly"
echo "Last 50 lines of the log:"
tail -n 50 /tmp/openhands-e2e-test.log
exit 1
fi
# Check if frontend port is open
if nc -z localhost 12000; then
# Verify we can get HTML content
if curl -s http://localhost:12000 | grep -q "<html"; then
echo "SUCCESS: OpenHands is running and serving HTML content on port 12000"
break
else
echo "Port 12000 is open but not serving HTML content yet"
fi
else
echo "Frontend port 12000 is not open yet"
fi
# Show log output on each attempt
echo "Recent log output:"
tail -n 20 /tmp/openhands-e2e-test.log
# Wait before next attempt
echo "Waiting 10 seconds before next check..."
sleep 10
attempt=$((attempt + 1))
# Exit if we've reached the maximum number of attempts
if [ $attempt -gt $max_attempts ]; then
echo "ERROR: OpenHands failed to start after $max_attempts attempts"
echo "Last 50 lines of the log:"
tail -n 50 /tmp/openhands-e2e-test.log
exit 1
fi
done
# Final verification that the app is running
if ! nc -z localhost 12000 || ! curl -s http://localhost:12000 | grep -q "<html"; then
echo "ERROR: OpenHands is not running properly on port 12000"
echo "Last 50 lines of the log:"
tail -n 50 /tmp/openhands-e2e-test.log
exit 1
fi
# Print success message
echo "OpenHands is running successfully on port 12000"
- name: Run end-to-end tests
env:
GITHUB_TOKEN: ${{ secrets.E2E_TEST_GITHUB_TOKEN }}
LLM_MODEL: ${{ secrets.LLM_MODEL || 'gpt-4o' }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY || 'test-key' }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
run: |
# Check if the application is running
if ! nc -z localhost 12000; then
echo "ERROR: OpenHands is not running on port 12000"
echo "Last 50 lines of the log:"
tail -n 50 /tmp/openhands-e2e-test.log
exit 1
fi
# Run the tests with detailed output
cd tests/e2e
poetry run python -m pytest \
test_settings.py::test_github_token_configuration \
test_conversation.py::test_conversation_start \
test_browsing_catchphrase.py::test_browsing_catchphrase \
test_multi_conversation_resume.py::test_multi_conversation_resume \
-v --no-header --capture=no --timeout=900
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: tests/e2e/test-results/
retention-days: 30
- name: Upload OpenHands logs
if: always()
uses: actions/upload-artifact@v7
with:
name: openhands-logs
path: |
/tmp/openhands-e2e-test.log
/tmp/openhands-e2e-build.log
/tmp/openhands-backend.log
/tmp/openhands-frontend.log
/tmp/backend-health-check.log
/tmp/frontend-check.log
/tmp/vite-config.log
/tmp/makefile-contents.log
retention-days: 30
- name: Cleanup
if: always()
run: |
# Stop OpenHands processes
echo "Stopping OpenHands processes..."
pkill -f "python -m openhands.server" || true
pkill -f "npm run dev" || true
pkill -f "make run" || true
# Print process status for debugging
echo "Checking if any OpenHands processes are still running:"
ps aux | grep -E "openhands|npm run dev" || true
+9 -31
View File
@@ -2,14 +2,12 @@
name: PR Review by OpenHands
on:
# Use pull_request for same-repo PRs so workflow changes can self-verify in PRs.
# TEMPORARY MITIGATION (Clinejection hardening)
#
# We temporarily avoid `pull_request_target` here. We'll restore it after the PR review
# workflow is fully hardened for untrusted execution.
pull_request:
types: [opened, ready_for_review, labeled, review_requested]
# Use pull_request_target for fork PRs.
# The bot token used here is intentionally scoped to PR review operations,
# so the remaining blast radius is bounded even though PR content is untrusted.
pull_request_target:
types: [opened, ready_for_review, labeled, review_requested]
permissions:
contents: read
@@ -18,33 +16,13 @@ permissions:
jobs:
pr-review:
# Run on same-repo PRs via pull_request and on fork PRs via pull_request_target.
# Trigger when one of the following conditions is met:
# 1. A new non-draft PR is opened by a non-first-time contributor, OR
# 2. A draft PR is converted to ready for review by a non-first-time contributor, OR
# 3. The 'review-this' label is added, OR
# 4. openhands-agent or all-hands-bot is requested as a reviewer
# Note: FIRST_TIME_CONTRIBUTOR and NONE PRs require manual trigger via label/reviewer request.
# Trigger logic:
# 1. Route same-repo PRs through `pull_request` and fork PRs through `pull_request_target`
# 2. Auto-trigger on `opened` / `ready_for_review` for non-first-time contributors
# 3. Always allow manual triggers via `review-this` or reviewer request
# The author association check is duplicated intentionally for both
# auto-triggered actions (`opened` and `ready_for_review`).
# Note: fork PRs will not have access to repository secrets under `pull_request`.
# Skip forks to avoid noisy failures until we restore a hardened `pull_request_target` flow.
if: |
github.event.pull_request.head.repo.full_name == github.repository &&
(
(
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository
) ||
(
github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.full_name != github.repository
)
) &&
(
(github.event.action == 'opened' && github.event.pull_request.draft == false && github.event.pull_request.author_association != 'FIRST_TIME_CONTRIBUTOR' && github.event.pull_request.author_association != 'NONE') ||
(github.event.action == 'ready_for_review' && github.event.pull_request.author_association != 'FIRST_TIME_CONTRIBUTOR' && github.event.pull_request.author_association != 'NONE') ||
(github.event.action == 'opened' && github.event.pull_request.draft == false) ||
github.event.action == 'ready_for_review' ||
(github.event.action == 'labeled' && github.event.label.name == 'review-this') ||
(
github.event.action == 'review_requested' &&
+4
View File
@@ -60,6 +60,10 @@ jobs:
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest --forked -n auto -s ./tests/unit --cov=openhands --cov-branch
env:
COVERAGE_FILE: ".coverage.${{ matrix.python_version }}"
- name: Run Runtime Tests with CLIRuntime
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -n 5 --reruns 2 --reruns-delay 3 -s tests/runtime/test_bash.py --cov=openhands --cov-branch
env:
COVERAGE_FILE: ".coverage.runtime.${{ matrix.python_version }}"
- name: Store coverage file
uses: actions/upload-artifact@v7
with:
+1
View File
@@ -36,6 +36,7 @@ Full details in our [Development Guide](./Development.md).
- **[Frontend](./frontend/README.md)** - React application
- **[App Server (V1)](./openhands/app_server/README.md)** - Current FastAPI application server and REST API modules
- **[Runtime](./openhands/runtime/README.md)** - Execution environments
- **[Evaluation](https://github.com/OpenHands/benchmarks)** - Testing and benchmarks
## What Can You Build?
+1 -1
View File
@@ -16,7 +16,7 @@ open source community:
#### [Aider](https://github.com/paul-gauthier/aider)
- License: Apache License 2.0
- Description: AI pair programming tool. OpenHands has adapted and integrated its linter module for code-related tasks.
- Description: AI pair programming tool. OpenHands has adapted and integrated its linter module for code-related tasks in [`agentskills utilities`](https://github.com/OpenHands/OpenHands/tree/main/openhands/runtime/plugins/agent_skills/utils/aider)
#### [BrowserGym](https://github.com/ServiceNow/BrowserGym)
- License: Apache License 2.0
+11
View File
@@ -309,6 +309,16 @@ poetry run pytest ./tests/unit/test_*.py
---
## Using Existing Docker Images
To reduce build time, you can use an existing runtime image:
```bash
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.2-nikolaik
```
---
## Help
```bash
@@ -329,3 +339,4 @@ make help
- [/tests/unit/README.md](./tests/unit/README.md): Guide to writing and running unit tests
- [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks): Documentation for the evaluation framework and benchmarks
- [/skills/README.md](./skills/README.md): Information about the skills architecture and implementation
- [/openhands/runtime/README.md](./openhands/runtime/README.md): Documentation for the runtime environment and execution model
+1
View File
@@ -88,6 +88,7 @@ USER openhands
COPY --chown=openhands:openhands --chmod=770 ./skills ./skills
COPY --chown=openhands:openhands --chmod=770 ./openhands ./openhands
COPY --chown=openhands:openhands --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
COPY --chown=openhands:openhands pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./
# Add this line to set group ownership of all files/directories not already in "app" group
+12
View File
@@ -23,6 +23,18 @@ if [ -z "$WORKSPACE_MOUNT_PATH" ]; then
unset WORKSPACE_BASE
fi
if [[ "$INSTALL_THIRD_PARTY_RUNTIMES" == "true" ]]; then
echo "Downloading and installing third_party_runtimes..."
echo "Warning: Third-party runtimes are provided as-is, not actively supported and may be removed in future releases."
if pip install 'openhands-ai[third_party_runtimes]' -qqq 2> >(tee /dev/stderr); then
echo "third_party_runtimes installed successfully."
else
echo "Failed to install third_party_runtimes." >&2
exit 1
fi
fi
if [[ "$SANDBOX_USER_ID" -eq 0 ]]; then
echo "Running OpenHands as root"
export RUN_AS_OPENHANDS=false
+4 -4
View File
@@ -3,9 +3,9 @@ repos:
rev: v5.0.0
hooks:
- id: trailing-whitespace
exclude: ^(docs/|modules/|python/|openhands-ui/|enterprise/)
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
- id: end-of-file-fixer
exclude: ^(docs/|modules/|python/|openhands-ui/|enterprise/)
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
- id: check-yaml
args: ["--allow-multiple-documents"]
- id: debug-statements
@@ -37,12 +37,12 @@ repos:
entry: ruff check --config dev_config/python/ruff.toml
types_or: [python, pyi, jupyter]
args: [--fix, --unsafe-fixes]
exclude: ^(enterprise/)
exclude: ^(third_party/|enterprise/)
# Run the formatter.
- id: ruff-format
entry: ruff format --config dev_config/python/ruff.toml
types_or: [python, pyi, jupyter]
exclude: ^(enterprise/)
exclude: ^(third_party/|enterprise/)
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.15.0
+1 -1
View File
@@ -10,7 +10,7 @@ strict_optional = True
disable_error_code = type-abstract
# Exclude third-party runtime directory from type checking
exclude = (enterprise/)
exclude = (third_party/|enterprise/)
[mypy-openai.*]
follow_imports = skip
+1 -1
View File
@@ -1,5 +1,5 @@
# Exclude third-party runtime directory from linting
exclude = ["enterprise/"]
exclude = ["third_party/", "enterprise/"]
[lint]
select = [
+9
View File
@@ -61,6 +61,13 @@ export LITE_LLM_API_KEY=<your LLM API key>
python enterprise_local/convert_to_env.py
```
You'll also need to set up the runtime image, so that the dev server doesn't try to rebuild it.
```
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:main-nikolaik
docker pull $SANDBOX_RUNTIME_CONTAINER_IMAGE
```
By default the application will log in json, you can override.
```
@@ -196,6 +203,7 @@ And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by
"REDIS_HOST": "localhost:6379",
"OPENHANDS": "<YOUR LOCAL OPENHANDS DIR>",
"FRONTEND_DIRECTORY": "<YOUR LOCAL OPENHANDS DIR>/frontend/build",
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
"GITHUB_APP_ID": "1062351",
@@ -229,6 +237,7 @@ And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by
"REDIS_HOST": "localhost:6379",
"OPENHANDS": "<YOUR LOCAL OPENHANDS DIR>",
"FRONTEND_DIRECTORY": "<YOUR LOCAL OPENHANDS DIR>/frontend/build",
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
"GITHUB_APP_ID": "1062351",
+4 -1
View File
@@ -6547,7 +6547,7 @@ python-docx = "*"
python-dotenv = "*"
python-frontmatter = ">=1.1"
python-json-logger = ">=3.2.1"
python-multipart = ">=0.0.26"
python-multipart = ">=0.0.22"
python-pptx = "*"
python-socketio = "5.14"
pythonnet = {version = "*", markers = "sys_platform == \"win32\""}
@@ -6571,6 +6571,9 @@ uvicorn = "*"
whatthepatch = ">=1.0.6"
zope-interface = "7.2"
[package.extras]
third-party-runtimes = ["daytona (==0.24.2)", "e2b-code-interpreter (>=2)", "modal (>=0.66.26,<1.2)", "runloop-api-client (==0.50)"]
[package.source]
type = "directory"
url = ".."
+24 -1
View File
@@ -2,7 +2,15 @@ import asyncio
import hashlib
import json
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from fastapi import (
APIRouter,
BackgroundTasks,
Depends,
Header,
HTTPException,
Request,
status,
)
from fastapi.responses import JSONResponse
from integrations.gitlab.gitlab_manager import GitlabManager
from integrations.gitlab.gitlab_service import SaaSGitLabService
@@ -15,12 +23,15 @@ from integrations.models import Message, SourceType
from integrations.types import GitLabResourceType
from integrations.utils import GITLAB_WEBHOOK_URL, IS_LOCAL_DEPLOYMENT
from pydantic import BaseModel
from server.auth.constants import AUTOMATION_EVENT_FORWARDING_ENABLED
from server.auth.token_manager import TokenManager
from server.services.automation_event_service import AutomationEventService
from storage.gitlab_webhook import GitlabWebhook
from storage.gitlab_webhook_store import GitlabWebhookStore
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
from openhands.integrations.provider import ProviderType
from openhands.server.shared import sio
from openhands.server.user_auth import get_user_id
@@ -29,6 +40,7 @@ webhook_store = GitlabWebhookStore()
token_manager = TokenManager()
gitlab_manager = GitlabManager(token_manager)
automation_event_service = AutomationEventService(token_manager)
# Request/Response models
@@ -82,6 +94,7 @@ async def verify_gitlab_signature(
@gitlab_integration_router.post('/gitlab/events')
async def gitlab_events(
request: Request,
background_tasks: BackgroundTasks,
x_gitlab_token: str = Header(None),
x_openhands_webhook_id: str = Header(None),
x_openhands_user_id: str = Header(None),
@@ -112,6 +125,16 @@ async def gitlab_events(
content={'message': 'Duplicate GitLab event ignored.'},
)
# Forward to automation service (fire-and-forget background task)
if AUTOMATION_EVENT_FORWARDING_ENABLED:
background_tasks.add_task(
automation_event_service.forward_event,
provider=ProviderType.GITLAB,
payload=payload_data,
installation_id=x_openhands_webhook_id,
)
# Existing resolver bot processing
message = Message(
source=SourceType.GITLAB,
message={
@@ -163,7 +163,8 @@ class AutomationEventService:
org_id = await self._resolve_git_org(provider, git_org_name)
# Fallback for personal repos (owner_type indicates individual user)
if not org_id and owner_type == 'User':
# GitHub uses 'User', GitLab uses 'user'
if not org_id and owner_type and owner_type.lower() == 'user':
org_id = await self._resolve_personal_org(provider, owner_id)
if org_id:
logger.info(
@@ -206,6 +207,18 @@ class AutomationEventService:
owner = repo.get('owner', {})
return owner.get('login'), owner.get('type'), owner.get('id')
if provider == ProviderType.GITLAB:
# GitLab uses 'project' instead of 'repository'
# path_with_namespace is like "org-name/repo-name" or "user-name/repo-name"
project = payload.get('project', {})
path_with_namespace = project.get('path_with_namespace', '')
git_org = path_with_namespace.split('/')[0] if path_with_namespace else None
namespace = project.get('namespace', {})
# GitLab uses 'group' for organizations and 'user' for personal projects
owner_type = namespace.get('kind')
owner_id = namespace.get('id')
return git_org, owner_type, owner_id
logger.warning(f'Unsupported provider ({provider.value})')
return None, None, None
@@ -826,3 +826,249 @@ class TestCacheHelpers:
service = create_service(mock_token_manager)
# Should not raise
await service._set_cached_value('test-key', 'test-value', 3600)
# =============================================================================
# GitLab-specific tests
# =============================================================================
@pytest.fixture
def gitlab_group_payload():
"""Create a sample GitLab webhook payload for a group-owned project."""
return {
'object_kind': 'issue',
'event_type': 'issue',
'project': {
'id': 123456,
'path_with_namespace': 'test-org/test-repo',
'visibility': 'public',
'namespace': {
'id': 789,
'kind': 'group',
'name': 'test-org',
},
},
'user': {
'id': 12345,
'username': 'testuser',
},
'object_attributes': {
'id': 1,
'iid': 1,
'title': 'Test Issue',
},
}
@pytest.fixture
def gitlab_user_payload():
"""Create a sample GitLab webhook payload for a user-owned project."""
return {
'object_kind': 'issue',
'event_type': 'issue',
'project': {
'id': 654321,
'path_with_namespace': 'testuser/personal-repo',
'visibility': 'private',
'namespace': {
'id': 12345,
'kind': 'user',
'name': 'testuser',
},
},
'user': {
'id': 12345,
'username': 'testuser',
},
'object_attributes': {
'id': 2,
'iid': 1,
'title': 'Personal Issue',
},
}
class TestExtractOwnerInfoGitLab:
"""Tests for _extract_owner_info method with GitLab payloads."""
def test_extract_gitlab_group_owner(self, mock_token_manager, gitlab_group_payload):
"""
GIVEN: GitLab payload for a group-owned project
WHEN: _extract_owner_info is called
THEN: Returns correct git_org, owner_type, owner_id
"""
with patch('server.services.automation_event_service.sio'):
service = create_service(mock_token_manager)
git_org, owner_type, owner_id = service._extract_owner_info(
ProviderType.GITLAB, gitlab_group_payload
)
assert git_org == 'test-org'
assert owner_type == 'group'
assert owner_id == 789
def test_extract_gitlab_user_owner(self, mock_token_manager, gitlab_user_payload):
"""
GIVEN: GitLab payload for a user-owned project
WHEN: _extract_owner_info is called
THEN: Returns correct git_org, owner_type, owner_id
"""
with patch('server.services.automation_event_service.sio'):
service = create_service(mock_token_manager)
git_org, owner_type, owner_id = service._extract_owner_info(
ProviderType.GITLAB, gitlab_user_payload
)
assert git_org == 'testuser'
assert owner_type == 'user'
assert owner_id == 12345
def test_extract_gitlab_missing_project(self, mock_token_manager):
"""
GIVEN: GitLab payload without project data
WHEN: _extract_owner_info is called
THEN: Returns None values
"""
payload = {'object_kind': 'issue'}
with patch('server.services.automation_event_service.sio'):
service = create_service(mock_token_manager)
git_org, owner_type, owner_id = service._extract_owner_info(
ProviderType.GITLAB, payload
)
assert git_org is None
assert owner_type is None
assert owner_id is None
class TestResolveOrgContextGitLab:
"""Tests for _resolve_org_context method with GitLab payloads."""
@pytest.mark.asyncio
async def test_resolve_gitlab_group_org(
self, mock_token_manager, mock_org_git_claim, gitlab_group_payload
):
"""
GIVEN: GitLab payload for a group project with claimed org
WHEN: _resolve_org_context is called
THEN: Returns correct OrgContext with org_id
"""
mock_redis = AsyncMock()
mock_redis.get = AsyncMock(return_value=None) # Cache miss
mock_redis.setex = AsyncMock()
with patch(
'server.services.automation_event_service.resolve_org_for_repo',
new_callable=AsyncMock,
return_value=mock_org_git_claim.org_id,
), patch('server.services.automation_event_service.sio') as mock_sio:
mock_sio.manager.redis = mock_redis
service = create_service(mock_token_manager)
result = await service._resolve_org_context(
ProviderType.GITLAB, gitlab_group_payload
)
assert result is not None
assert result.org_id == mock_org_git_claim.org_id
assert result.git_org == 'test-org'
@pytest.mark.asyncio
async def test_resolve_gitlab_user_personal_org_fallback(
self, mock_token_manager, mock_org_git_claim, gitlab_user_payload
):
"""
GIVEN: GitLab payload for a user project (not claimed via OrgGitClaim)
WHEN: _resolve_org_context is called
THEN: Falls back to personal org resolution
"""
mock_redis = AsyncMock()
mock_redis.get = AsyncMock(return_value=None) # Cache miss
mock_redis.setex = AsyncMock()
with patch(
'server.services.automation_event_service.resolve_org_for_repo',
new_callable=AsyncMock,
return_value=None, # No OrgGitClaim for user
), patch('server.services.automation_event_service.sio') as mock_sio:
mock_sio.manager.redis = mock_redis
service = create_service(mock_token_manager)
# Mock _resolve_personal_org to return a personal org
service._resolve_personal_org = AsyncMock(
return_value=mock_org_git_claim.org_id
)
result = await service._resolve_org_context(
ProviderType.GITLAB, gitlab_user_payload
)
assert result is not None
assert result.org_id == mock_org_git_claim.org_id
assert result.git_org == 'testuser'
# Verify personal org fallback was called with GitLab user ID
service._resolve_personal_org.assert_called_once_with(
ProviderType.GITLAB, 12345
)
@pytest.mark.asyncio
async def test_resolve_gitlab_no_org_found(
self, mock_token_manager, gitlab_group_payload
):
"""
GIVEN: GitLab payload for a project with no org claim and no personal org
WHEN: _resolve_org_context is called
THEN: Returns None
"""
mock_redis = AsyncMock()
mock_redis.get = AsyncMock(return_value=None)
mock_redis.setex = AsyncMock()
with patch(
'server.services.automation_event_service.resolve_org_for_repo',
new_callable=AsyncMock,
return_value=None,
), patch('server.services.automation_event_service.sio') as mock_sio:
mock_sio.manager.redis = mock_redis
service = create_service(mock_token_manager)
result = await service._resolve_org_context(
ProviderType.GITLAB, gitlab_group_payload
)
# Group owner doesn't fall back to personal org
assert result is None
class TestResolveGitOrgGitLab:
"""Tests for _resolve_git_org method with GitLab provider."""
@pytest.mark.asyncio
async def test_resolve_gitlab_org_includes_provider_in_cache_key(
self, mock_token_manager, mock_org_git_claim
):
"""
GIVEN: GitLab provider with an org name
WHEN: _resolve_git_org is called
THEN: Cache key includes the gitlab provider name
"""
mock_redis = AsyncMock()
mock_redis.get = AsyncMock(return_value=None)
mock_redis.setex = AsyncMock()
with patch(
'server.services.automation_event_service.resolve_org_for_repo',
new_callable=AsyncMock,
return_value=mock_org_git_claim.org_id,
), patch('server.services.automation_event_service.sio') as mock_sio:
mock_sio.manager.redis = mock_redis
service = create_service(mock_token_manager)
await service._resolve_git_org(ProviderType.GITLAB, 'Test-Org')
# Verify cache key includes provider and normalized org name
get_call_args = mock_redis.get.call_args[0][0]
assert 'gitlab' in get_call_args
assert 'test-org' in get_call_args # Lowercase normalized
+20 -4
View File
@@ -8,16 +8,23 @@ import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import BackgroundTasks
from fastapi.responses import JSONResponse
from server.routes.integration.gitlab import gitlab_events
@pytest.fixture
def mock_background_tasks():
"""Create a mock BackgroundTasks."""
return MagicMock(spec=BackgroundTasks)
@pytest.mark.asyncio
@patch('server.routes.integration.gitlab.verify_gitlab_signature')
@patch('server.routes.integration.gitlab.gitlab_manager')
@patch('server.routes.integration.gitlab.sio')
async def test_gitlab_events_deduplication_with_object_id(
mock_sio, mock_gitlab_manager, mock_verify_signature
mock_sio, mock_gitlab_manager, mock_verify_signature, mock_background_tasks
):
"""Test that duplicate GitLab events are deduplicated using object_attributes.id."""
# Setup mocks
@@ -47,6 +54,7 @@ async def test_gitlab_events_deduplication_with_object_id(
# Call the endpoint
response = await gitlab_events(
request=mock_request,
background_tasks=mock_background_tasks,
x_gitlab_token='test_token',
x_openhands_webhook_id='test_webhook_id',
x_openhands_user_id='test_user_id',
@@ -70,6 +78,7 @@ async def test_gitlab_events_deduplication_with_object_id(
# Call the endpoint again with the same payload
response = await gitlab_events(
request=mock_request,
background_tasks=mock_background_tasks,
x_gitlab_token='test_token',
x_openhands_webhook_id='test_webhook_id',
x_openhands_user_id='test_user_id',
@@ -92,7 +101,7 @@ async def test_gitlab_events_deduplication_with_object_id(
@patch('server.routes.integration.gitlab.gitlab_manager')
@patch('server.routes.integration.gitlab.sio')
async def test_gitlab_events_deduplication_without_object_id(
mock_sio, mock_gitlab_manager, mock_verify_signature
mock_sio, mock_gitlab_manager, mock_verify_signature, mock_background_tasks
):
"""Test that GitLab events without object_attributes.id are deduplicated using hash of payload."""
# Setup mocks
@@ -127,6 +136,7 @@ async def test_gitlab_events_deduplication_without_object_id(
# Call the endpoint
response = await gitlab_events(
request=mock_request,
background_tasks=mock_background_tasks,
x_gitlab_token='test_token',
x_openhands_webhook_id='test_webhook_id',
x_openhands_user_id='test_user_id',
@@ -150,6 +160,7 @@ async def test_gitlab_events_deduplication_without_object_id(
# Call the endpoint again with the same payload
response = await gitlab_events(
request=mock_request,
background_tasks=mock_background_tasks,
x_gitlab_token='test_token',
x_openhands_webhook_id='test_webhook_id',
x_openhands_user_id='test_user_id',
@@ -172,7 +183,7 @@ async def test_gitlab_events_deduplication_without_object_id(
@patch('server.routes.integration.gitlab.gitlab_manager')
@patch('server.routes.integration.gitlab.sio')
async def test_gitlab_events_different_payloads_not_deduplicated(
mock_sio, mock_gitlab_manager, mock_verify_signature
mock_sio, mock_gitlab_manager, mock_verify_signature, mock_background_tasks
):
"""Test that different GitLab events are not deduplicated."""
# Setup mocks
@@ -196,6 +207,7 @@ async def test_gitlab_events_different_payloads_not_deduplicated(
# Call the endpoint with first payload
response1 = await gitlab_events(
request=mock_request1,
background_tasks=mock_background_tasks,
x_gitlab_token='test_token',
x_openhands_webhook_id='test_webhook_id',
x_openhands_user_id='test_user_id',
@@ -223,6 +235,7 @@ async def test_gitlab_events_different_payloads_not_deduplicated(
# Call the endpoint with second payload
response2 = await gitlab_events(
request=mock_request2,
background_tasks=mock_background_tasks,
x_gitlab_token='test_token',
x_openhands_webhook_id='test_webhook_id',
x_openhands_user_id='test_user_id',
@@ -242,7 +255,7 @@ async def test_gitlab_events_different_payloads_not_deduplicated(
@patch('server.routes.integration.gitlab.gitlab_manager')
@patch('server.routes.integration.gitlab.sio')
async def test_gitlab_events_multiple_identical_payloads_deduplicated(
mock_sio, mock_gitlab_manager, mock_verify_signature
mock_sio, mock_gitlab_manager, mock_verify_signature, mock_background_tasks
):
"""Test that multiple identical GitLab events are properly deduplicated."""
# Setup mocks
@@ -273,6 +286,7 @@ async def test_gitlab_events_multiple_identical_payloads_deduplicated(
# Call the endpoint first time
response1 = await gitlab_events(
request=mock_request,
background_tasks=mock_background_tasks,
x_gitlab_token='test_token',
x_openhands_webhook_id='test_webhook_id',
x_openhands_user_id='test_user_id',
@@ -298,6 +312,7 @@ async def test_gitlab_events_multiple_identical_payloads_deduplicated(
# Call the endpoint second time with the same payload
response2 = await gitlab_events(
request=mock_request,
background_tasks=mock_background_tasks,
x_gitlab_token='test_token',
x_openhands_webhook_id='test_webhook_id',
x_openhands_user_id='test_user_id',
@@ -321,6 +336,7 @@ async def test_gitlab_events_multiple_identical_payloads_deduplicated(
# Call the endpoint third time with the same payload
response3 = await gitlab_events(
request=mock_request,
background_tasks=mock_background_tasks,
x_gitlab_token='test_token',
x_openhands_webhook_id='test_webhook_id',
x_openhands_user_id='test_user_id',
+14 -7
View File
@@ -196,16 +196,23 @@ describe("useWebSocket", () => {
const onCloseSpy = vi.fn();
const options = { onClose: onCloseSpy };
const closeLink = ws.link("ws://close-test.com/ws");
mswServer.use(
closeLink.addEventListener("connection", ({ client, server }) => {
server.connect();
client.close(1000, "Normal closure");
}),
const { result, unmount } = renderHook(() =>
useWebSocket("ws://acme.com/ws", options),
);
renderHook(() => useWebSocket("ws://close-test.com/ws", options));
// Wait for connection to be established
await waitFor(() => {
expect(result.current.isConnected).toBe(true);
});
// Reset spy after connection is established to ignore any spurious
// close events fired by the MSW mock during the handshake.
onCloseSpy.mockClear();
// Unmount to trigger close
unmount();
// Wait for onClose handler to be called
await waitFor(() => {
expect(onCloseSpy).toHaveBeenCalledOnce();
});
@@ -35,8 +35,6 @@ const VALID_OSS_CONFIG: WebClientConfig = {
error_message: null,
updated_at: "2024-01-14T10:00:00Z",
github_app_slug: null,
gitlab_enabled: false,
slack_enabled: false,
};
const VALID_SAAS_CONFIG: WebClientConfig = {
@@ -60,8 +58,6 @@ const VALID_SAAS_CONFIG: WebClientConfig = {
error_message: null,
updated_at: "2024-01-14T10:00:00Z",
github_app_slug: null,
gitlab_enabled: false,
slack_enabled: false,
};
const queryClient = new QueryClient();
@@ -272,10 +268,7 @@ describe("Content", () => {
it("should render the 'Configure GitHub Repositories' button if SaaS mode and github_app_slug exists", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const { rerender } = renderGitSettingsScreen();
@@ -290,24 +283,15 @@ describe("Content", () => {
rerender();
await waitFor(() => {
// wait until queries are resolved
expect(queryClient.isFetching()).toBe(0);
button = screen.queryByTestId("configure-github-repositories-button");
expect(button).not.toBeInTheDocument();
expect(screen.queryByTestId("gitlab-status-text")).not.toBeInTheDocument();
expect(
screen.queryByTestId("install-slack-app-button"),
).not.toBeInTheDocument();
expect(screen.queryByTestId("submit-button")).not.toBeInTheDocument();
expect(
screen.queryByTestId("disconnect-tokens-button"),
).not.toBeInTheDocument();
});
getConfigSpy.mockResolvedValue({
...VALID_SAAS_CONFIG,
providers_configured: ["gitlab"],
github_app_slug: "test-slug",
gitlab_enabled: true,
slack_enabled: true,
});
queryClient.invalidateQueries();
rerender();
@@ -315,8 +299,6 @@ describe("Content", () => {
await waitFor(() => {
button = screen.getByTestId("configure-github-repositories-button");
expect(button).toBeInTheDocument();
expect(screen.getByTestId("gitlab-status-text")).toBeInTheDocument();
expect(screen.getByTestId("install-slack-app-button")).toBeInTheDocument();
expect(screen.queryByTestId("submit-button")).not.toBeInTheDocument();
expect(
screen.queryByTestId("disconnect-tokens-button"),
@@ -632,49 +614,10 @@ describe("GitLab Webhook Manager Integration", () => {
});
});
it("should render configured GitLab and Slack sections in SaaS mode without APP_SLUG", async () => {
it("should not render GitLab webhook manager in SaaS mode without APP_SLUG", async () => {
// Arrange
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getConfigSpy.mockResolvedValue({
...VALID_SAAS_CONFIG,
providers_configured: ["gitlab"],
gitlab_enabled: true,
slack_enabled: true,
});
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {},
});
// Act
renderGitSettingsScreen();
await screen.findByTestId("git-settings-screen");
// Assert
await waitFor(() => {
expect(
screen.queryByTestId("configure-github-repositories-button"),
).not.toBeInTheDocument();
expect(screen.getByTestId("gitlab-status-text")).toBeInTheDocument();
expect(screen.getByTestId("install-slack-app-button")).toBeInTheDocument();
expect(
screen.queryByText("GITLAB$WEBHOOK_MANAGER_TITLE"),
).not.toBeInTheDocument();
});
});
it("should not render GitLab or Slack sections when the backend does not enable them", async () => {
// Arrange
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getConfigSpy.mockResolvedValue(VALID_SAAS_CONFIG);
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {},
});
// Act
renderGitSettingsScreen();
@@ -682,25 +625,19 @@ describe("GitLab Webhook Manager Integration", () => {
// Assert
await waitFor(() => {
expect(screen.queryByTestId("gitlab-status-text")).not.toBeInTheDocument();
expect(
screen.queryByTestId("install-slack-app-button"),
).not.toBeInTheDocument();
expect(
screen.queryByText("GITLAB$WEBHOOK_MANAGER_TITLE"),
).not.toBeInTheDocument();
});
});
it("should not render GitLab webhook manager when the token is not set", async () => {
it("should not render GitLab webhook manager when token is not set", async () => {
// Arrange
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getConfigSpy.mockResolvedValue({
...VALID_SAAS_CONFIG,
providers_configured: ["gitlab"],
gitlab_enabled: true,
});
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
@@ -713,7 +650,6 @@ describe("GitLab Webhook Manager Integration", () => {
// Assert
await waitFor(() => {
expect(screen.getByTestId("gitlab-status-text")).toBeInTheDocument();
expect(
screen.queryByText("GITLAB$WEBHOOK_MANAGER_TITLE"),
).not.toBeInTheDocument();
@@ -43,6 +43,4 @@ export interface WebClientConfig {
error_message: string | null;
updated_at: string;
github_app_slug: string | null;
gitlab_enabled?: boolean;
slack_enabled?: boolean;
}
-4
View File
@@ -62,8 +62,6 @@ export const createMockWebClientConfig = (
error_message: null,
updated_at: new Date().toISOString(),
github_app_slug: null,
gitlab_enabled: false,
slack_enabled: false,
...overrides,
});
@@ -427,8 +425,6 @@ export const SETTINGS_HANDLERS = [
error_message: null,
updated_at: new Date().toISOString(),
github_app_slug: mockSaas ? "openhands" : null,
gitlab_enabled: false,
slack_enabled: false,
};
return HttpResponse.json(config);
+6 -7
View File
@@ -181,9 +181,8 @@ function GitSettingsScreen() {
!bitbucketDCHostInputHasValue &&
!azureDevOpsHostInputHasValue &&
!forgejoHostInputHasValue;
const shouldRenderGitHubConfigureButton = isSaas && config?.github_app_slug;
const shouldRenderGitLabSection = isSaas && Boolean(config?.gitlab_enabled);
const shouldRenderSlackSection = isSaas && Boolean(config?.slack_enabled);
const shouldRenderExternalConfigureButtons =
isSaas && config?.github_app_slug;
const shouldRenderProjectManagementIntegrations =
config?.feature_flags?.enable_jira ||
config?.feature_flags?.enable_jira_dc ||
@@ -197,7 +196,7 @@ function GitSettingsScreen() {
>
{!isLoading && (
<div className="flex flex-col">
{shouldRenderGitHubConfigureButton && (
{shouldRenderExternalConfigureButtons && !isLoading && (
<>
<div className="pb-1 flex flex-col">
<h3 className="text-xl font-medium text-white">
@@ -211,7 +210,7 @@ function GitSettingsScreen() {
</>
)}
{shouldRenderGitLabSection && (
{shouldRenderExternalConfigureButtons && !isLoading && (
<>
<div className="mt-6 flex flex-col gap-4 pb-8">
<Typography.H3 className="text-xl">
@@ -238,7 +237,7 @@ function GitSettingsScreen() {
</>
)}
{shouldRenderSlackSection && (
{shouldRenderExternalConfigureButtons && !isLoading && (
<>
<div className="pb-1 mt-6 flex flex-col">
<h3 className="text-xl font-medium text-white">
@@ -347,7 +346,7 @@ function GitSettingsScreen() {
{isLoading && <GitSettingInputsSkeleton />}
<div className="flex gap-6 p-6 justify-end">
{!isSaas && (
{!shouldRenderExternalConfigureButtons && (
<>
<BrandButton
testId="disconnect-tokens-button"
@@ -1304,10 +1304,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
plan_path = self._compute_plan_path(project_dir, git_provider)
tools = get_planning_tools(plan_path=plan_path)
else:
tools = get_default_tools(
enable_browser=True,
enable_sub_agents=user.agent_settings.enable_sub_agents,
)
tools = get_default_tools(enable_browser=True)
# --- build AgentSettings and create agent ---------------------------
from fastmcp.mcp_config import MCPConfig
@@ -71,7 +71,7 @@ def get_agent_server_image() -> str:
# Prefixes for environment variables that should be auto-forwarded to agent-server
# These are typically configuration variables that affect the agent's behavior
AUTO_FORWARD_PREFIXES = ('LLM_', 'LMNR_')
AUTO_FORWARD_PREFIXES = ('LLM_',)
def get_agent_server_env() -> dict[str, str]:
@@ -80,10 +80,9 @@ def get_agent_server_env() -> dict[str, str]:
This function combines two sources of environment variables:
1. **Auto-forwarded variables**: Environment variables with certain prefixes
(e.g., LLM_*, LMNR_*) are automatically forwarded to the agent-server container.
(e.g., LLM_*) are automatically forwarded to the agent-server container.
This ensures that LLM configuration like timeouts and retry settings
work correctly in the two-container V1 architecture, as well as
Laminar monitoring/analytics configuration.
work correctly in the two-container V1 architecture.
2. **Explicit overrides via OH_AGENT_SERVER_ENV**: A JSON string that allows
setting arbitrary environment variables in the agent-server container.
@@ -91,7 +90,6 @@ def get_agent_server_env() -> dict[str, str]:
Auto-forwarded prefixes:
- LLM_* : LLM configuration (timeout, retries, model settings, etc.)
- LMNR_* : Laminar monitoring/analytics configuration
Usage:
# Auto-forwarding (no action needed):
@@ -99,11 +97,6 @@ def get_agent_server_env() -> dict[str, str]:
export LLM_NUM_RETRIES=10
# These will automatically be available in the agent-server
# Auto-forwarding for Laminar:
export LMNR_PROJECT_API_KEY=your-api-key
export LMNR_BASE_URL=https://app.lmnr.ai
# These will automatically be available in the agent-server
# Explicit override via JSON:
OH_AGENT_SERVER_ENV='{"DEBUG": "true", "CUSTOM_VAR": "value"}'
@@ -58,11 +58,6 @@ def _get_maintenance_start_time() -> datetime | None:
return None
def _is_gitlab_enabled() -> bool:
"""Return whether GitLab OAuth is configured for the web client."""
return bool(os.getenv('GITLAB_APP_CLIENT_ID', '').strip())
def _get_providers_configured() -> list[ProviderType]:
"""Get configured OAuth providers from environment variables.
@@ -74,7 +69,7 @@ def _get_providers_configured() -> list[ProviderType]:
if os.getenv('GITHUB_APP_CLIENT_ID', '').strip():
providers.append(ProviderType.GITHUB)
if _is_gitlab_enabled():
if os.getenv('GITLAB_APP_CLIENT_ID', '').strip():
providers.append(ProviderType.GITLAB)
if os.getenv('BITBUCKET_APP_CLIENT_ID', '').strip():
@@ -96,16 +91,6 @@ def _get_github_app_slug() -> str | None:
return slug if slug else None
def _get_slack_enabled() -> bool:
"""Return whether Slack integration is fully configured for the web client."""
return (
os.getenv('SLACK_WEBHOOKS_ENABLED', 'false').lower() == 'true'
and bool(os.getenv('SLACK_CLIENT_ID', '').strip())
and bool(os.getenv('SLACK_CLIENT_SECRET', '').strip())
and bool(os.getenv('SLACK_SIGNING_SECRET', '').strip())
)
def _get_feature_flags() -> WebClientFeatureFlags:
"""Get feature flags from environment variables.
@@ -148,8 +133,6 @@ class DefaultWebClientConfigInjector(WebClientConfigInjector):
),
)
github_app_slug: str | None = Field(default_factory=_get_github_app_slug)
gitlab_enabled: bool = Field(default_factory=_is_gitlab_enabled)
slack_enabled: bool = Field(default_factory=_get_slack_enabled)
async def get_web_client_config(self) -> WebClientConfig:
from openhands.app_server.config import get_global_config
@@ -167,7 +150,5 @@ class DefaultWebClientConfigInjector(WebClientConfigInjector):
error_message=self.error_message,
updated_at=self.updated_at,
github_app_slug=self.github_app_slug,
gitlab_enabled=self.gitlab_enabled,
slack_enabled=self.slack_enabled,
)
return result
@@ -42,5 +42,3 @@ class WebClientConfig(DiscriminatedUnionMixin):
error_message: str | None
updated_at: datetime
github_app_slug: str | None
gitlab_enabled: bool = False
slack_enabled: bool = False
+10
View File
@@ -0,0 +1,10 @@
# OpenHands Architecture
Architecture diagrams and explanations for the OpenHands system.
## Documentation Sections
- [System Architecture Overview](./system-architecture.md) - Multi-tier architecture and component responsibilities
- [Conversation Startup & WebSocket Flow](./conversation-startup.md) - Runtime provisioning and real-time communication
- [Agent Execution & LLM Flow](./agent-execution.md) - LLM integration and action execution loop
- [Observability](./observability.md) - Logging, metrics, and monitoring
+92
View File
@@ -0,0 +1,92 @@
# Agent Execution & LLM Flow
When the agent executes inside the sandbox, it makes LLM calls through LiteLLM:
```mermaid
sequenceDiagram
autonumber
participant User as User (Browser)
participant AS as Agent Server
participant Agent as Agent<br/>(CodeAct)
participant LLM as LLM Class
participant Lite as LiteLLM
participant Proxy as LLM Proxy<br/>(llm-proxy.app.all-hands.dev)
participant Provider as LLM Provider<br/>(OpenAI, Anthropic, etc.)
participant AES as Action Execution Server
Note over User,AES: Agent Loop - LLM Call Flow
User->>AS: WebSocket: User message
AS->>Agent: Process message
Note over Agent: Build prompt from state
Agent->>LLM: completion(messages, tools)
Note over LLM: Apply config (model, temp, etc.)
alt Using OpenHands Provider
LLM->>Lite: litellm_proxy/{model}
Lite->>Proxy: POST /chat/completions
Note over Proxy: Auth, rate limit, routing
Proxy->>Provider: Forward request
Provider-->>Proxy: Response
Proxy-->>Lite: Response
else Using Direct Provider
LLM->>Lite: {provider}/{model}
Lite->>Provider: Direct API call
Provider-->>Lite: Response
end
Lite-->>LLM: ModelResponse
Note over LLM: Track metrics (cost, tokens)
LLM-->>Agent: Parsed response
Note over Agent: Parse action from response
AS->>User: WebSocket: Action event
Note over User,AES: Action Execution
AS->>AES: HTTP: Execute action
Note over AES: Run command/edit file
AES-->>AS: Observation
AS->>User: WebSocket: Observation event
Note over Agent: Update state
Note over Agent: Loop continues...
```
### LLM Components
| Component | Purpose | Location |
|-----------|---------|----------|
| **LLM Class** | Wrapper with retries, metrics, config | `openhands/llm/llm.py` |
| **LiteLLM** | Universal LLM API adapter | External library |
| **LLM Proxy** | OpenHands managed proxy for billing/routing | `llm-proxy.app.all-hands.dev` |
| **LLM Registry** | Manages multiple LLM instances | `openhands/llm/llm_registry.py` |
### Model Routing
```
User selects model
┌───────────────────┐
│ Model prefix? │
└───────────────────┘
├── openhands/claude-3-5 ──► Rewrite to litellm_proxy/claude-3-5
│ Base URL: llm-proxy.app.all-hands.dev
├── anthropic/claude-3-5 ──► Direct to Anthropic API
│ (User's API key)
├── openai/gpt-4 ──► Direct to OpenAI API
│ (User's API key)
└── azure/gpt-4 ──► Direct to Azure OpenAI
(User's API key + endpoint)
```
### LLM Proxy
When using `openhands/` prefixed models, requests are routed through a managed proxy.
See the [OpenHands documentation](https://docs.openhands.dev/) for details on supported models.
@@ -0,0 +1,68 @@
# Conversation Startup & WebSocket Flow
When a user starts a conversation, this sequence occurs:
```mermaid
sequenceDiagram
autonumber
participant User as User (Browser)
participant App as App Server
participant SS as Sandbox Service
participant RAPI as Runtime API
participant Pool as Warm Pool
participant Sandbox as Sandbox (Container)
participant AS as Agent Server
participant AES as Action Execution Server
Note over User,AES: Phase 1: Conversation Creation
User->>App: POST /api/conversations
Note over App: Authenticate user
App->>SS: Create sandbox
Note over SS,Pool: Phase 2: Runtime Provisioning
SS->>RAPI: POST /start (image, env, config)
RAPI->>Pool: Check for warm runtime
alt Warm runtime available
Pool-->>RAPI: Return warm runtime
Note over RAPI: Assign to session
else No warm runtime
RAPI->>Sandbox: Create new container
Sandbox->>AS: Start Agent Server
Sandbox->>AES: Start Action Execution Server
AES-->>AS: Ready
end
RAPI-->>SS: Runtime URL + session API key
SS-->>App: Sandbox info
App-->>User: Conversation ID + Sandbox URL
Note over User,AES: Phase 3: Direct WebSocket Connection
User->>AS: WebSocket: /sockets/events/{id}
AS-->>User: Connection accepted
AS->>User: Replay historical events
Note over User,AES: Phase 4: User Sends Message
User->>AS: WebSocket: SendMessageRequest
Note over AS: Agent processes message
Note over AS: LLM call → generate action
Note over User,AES: Phase 5: Action Execution Loop
loop Agent Loop
AS->>AES: HTTP: Execute action
Note over AES: Run in sandbox
AES-->>AS: Observation result
AS->>User: WebSocket: Event update
Note over AS: Update state, next action
end
Note over User,AES: Phase 6: Task Complete
AS->>User: WebSocket: AgentStateChanged (FINISHED)
```
### Key Points
1. **Initial Setup via App Server**: The App Server handles authentication and coordinates with the Sandbox Service
2. **Runtime API Provisioning**: The Sandbox Service calls the Runtime API, which checks for warm runtimes before creating new containers
3. **Warm Pool Optimization**: Pre-warmed runtimes reduce startup latency significantly
4. **Direct WebSocket to Sandbox**: Once created, the user's browser connects **directly** to the Agent Server inside the sandbox
5. **App Server Not in Hot Path**: After connection, all real-time communication bypasses the App Server entirely
6. **Agent Server Orchestrates**: The Agent Server manages the AI loop, calling the Action Execution Server for actual command execution
+85
View File
@@ -0,0 +1,85 @@
# Observability
OpenHands provides structured logging and metrics collection for monitoring and debugging.
> **SDK Documentation**: For detailed guidance on observability and metrics in agent development, see:
> - [SDK Observability Guide](https://docs.openhands.dev/sdk/guides/observability)
> - [SDK Metrics Guide](https://docs.openhands.dev/sdk/guides/metrics)
```mermaid
flowchart LR
subgraph Sources["Sources"]
Agent["Agent Server"]
App["App Server"]
Frontend["Frontend"]
end
subgraph Collection["Collection"]
JSONLog["JSON Logs<br/>(stdout)"]
Metrics["Metrics<br/>(Internal)"]
end
subgraph External["External (Optional)"]
LogAgg["Log Aggregator"]
Analytics["Analytics Service"]
end
Agent --> JSONLog
App --> JSONLog
App --> Metrics
JSONLog --> LogAgg
Frontend --> Analytics
```
### Structured Logging
OpenHands uses Python's standard logging library with structured JSON output support.
| Component | Format | Destination | Purpose |
|-----------|--------|-------------|---------|
| **Application Logs** | JSON (when `LOG_JSON=1`) | stdout | Debugging, error tracking |
| **Access Logs** | JSON (Uvicorn) | stdout | Request tracing |
| **LLM Debug Logs** | Plain text | File (optional) | LLM call debugging |
### JSON Log Format
When `LOG_JSON=1` is set, logs are emitted as single-line JSON for ingestion by log aggregators:
```json
{
"message": "Conversation started",
"severity": "INFO",
"conversation_id": "abc-123",
"user_id": "user-456",
"timestamp": "2024-01-15T10:30:00Z"
}
```
Additional context can be added using Python's logger `extra=` parameter (see [Python logging docs](https://docs.python.org/3/library/logging.html)).
### Metrics
| Metric | Tracked By | Storage | Purpose |
|--------|------------|---------|---------|
| **LLM Cost** | `Metrics` class | Conversation stats file | Billing, budget limits |
| **Token Usage** | `Metrics` class | Conversation stats file | Usage analytics |
| **Response Latency** | `Metrics` class | Conversation stats file | Performance monitoring |
### Conversation Stats Persistence
Per-conversation metrics are persisted for analytics:
```python
# Location: openhands/server/services/conversation_stats.py
ConversationStats:
- service_to_metrics: Dict[str, Metrics]
- accumulated_cost: float
- token_usage: TokenUsage
# Stored at: {file_store}/conversation_stats/{conversation_id}.pkl
```
### Integration with External Services
Structured JSON logging allows integration with any log aggregation service (e.g., ELK Stack, Loki, Splunk). Configure your log collector to ingest from container stdout/stderr.
@@ -0,0 +1,88 @@
# System Architecture Overview
OpenHands supports multiple deployment configurations. This document describes the core components and how they interact.
## Local/Docker Deployment
The simplest deployment runs everything locally or in Docker containers:
```mermaid
flowchart TB
subgraph Server["OpenHands Server"]
API["REST API<br/>(FastAPI)"]
ConvMgr["Conversation<br/>Manager"]
Runtime["Runtime<br/>Manager"]
end
subgraph Sandbox["Sandbox (Docker Container)"]
AES["Action Execution<br/>Server"]
Browser["Browser<br/>Environment"]
FS["File System"]
end
User["User"] -->|"HTTP/WebSocket"| API
API --> ConvMgr
ConvMgr --> Runtime
Runtime -->|"Provision"| Sandbox
Server -->|"Execute actions"| AES
AES --> Browser
AES --> FS
```
### Core Components
| Component | Purpose | Location |
|-----------|---------|----------|
| **Server** | REST API, conversation management, runtime orchestration | `openhands/server/` |
| **Runtime** | Abstract interface for sandbox execution | `openhands/runtime/` |
| **Action Execution Server** | Execute bash, file ops, browser actions | Inside sandbox |
| **EventStream** | Central event bus for all communication | `openhands/events/` |
## Scalable Deployment
For production deployments, OpenHands can be configured with a separate Runtime API service:
```mermaid
flowchart TB
subgraph AppServer["App Server"]
API["REST API"]
ConvMgr["Conversation<br/>Manager"]
end
subgraph RuntimeAPI["Runtime API (Optional)"]
RuntimeMgr["Runtime<br/>Manager"]
WarmPool["Warm Pool"]
end
subgraph Sandbox["Sandbox"]
AS["Agent Server"]
AES["Action Execution<br/>Server"]
end
User["User"] -->|"HTTP"| API
API --> ConvMgr
ConvMgr -->|"Provision"| RuntimeMgr
RuntimeMgr --> WarmPool
RuntimeMgr --> Sandbox
User -.->|"WebSocket"| AS
AS -->|"HTTP"| AES
```
This configuration enables:
- **Warm pool**: Pre-provisioned runtimes for faster startup
- **Direct WebSocket**: Users connect directly to their sandbox, bypassing the App Server
- **Horizontal scaling**: App Server and Runtime API can scale independently
### Runtime Options
OpenHands supports multiple runtime implementations:
| Runtime | Use Case |
|---------|----------|
| **DockerRuntime** | Local development, single-machine deployments |
| **RemoteRuntime** | Connect to externally managed sandboxes |
| **ModalRuntime** | Serverless execution via Modal |
See the [Runtime documentation](https://docs.openhands.dev/usage/architecture/runtime) for details.
+42
View File
@@ -14,11 +14,15 @@ from pydantic import SecretStr
from openhands.core.config import (
OpenHandsConfig,
)
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import (
PROVIDER_TOKEN_TYPE,
ProviderToken,
ProviderType,
)
from openhands.runtime.base import Runtime
from openhands.storage.data_models.secrets import Secrets
from openhands.utils.async_utils import GENERAL_TIMEOUT, call_async_from_sync
def get_provider_tokens():
@@ -72,6 +76,44 @@ def get_provider_tokens():
return secret_store.provider_tokens if secret_store else None
def initialize_repository_for_runtime(
runtime: Runtime,
immutable_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
selected_repository: str | None = None,
) -> str | None:
"""Initialize the repository for the runtime by cloning or initializing it,
running setup scripts, and setting up git hooks if present.
Args:
runtime: The runtime to initialize the repository for.
immutable_provider_tokens: (optional) Provider tokens to use for authentication.
selected_repository: (optional) The repository to use.
Returns:
The repository directory path if a repository was cloned, None otherwise.
"""
# If provider tokens are not provided, attempt to retrieve them from the environment
if not immutable_provider_tokens:
immutable_provider_tokens = get_provider_tokens()
logger.debug(f'Selected repository {selected_repository}.')
# Clone or initialize the repository using the runtime
repo_directory = call_async_from_sync(
runtime.clone_or_init_repo,
GENERAL_TIMEOUT,
immutable_provider_tokens,
selected_repository,
None,
)
# Run setup script if it exists in the repository
runtime.maybe_run_setup_script()
# Set up git hooks if pre-commit.sh exists in the repository
runtime.maybe_setup_git_hooks()
return repo_directory
def generate_sid(config: OpenHandsConfig, session_name: str | None = None) -> str:
"""Generate a session id based on the session name and the jwt secret.
+161
View File
@@ -0,0 +1,161 @@
# OpenHands Runtime
## Introduction
The OpenHands Runtime folder contains the core components responsible for executing actions and managing the runtime environment for the OpenHands project. This README provides an overview of the main components and their interactions.
You can learn more about how the runtime works in the [Docker Runtime](https://docs.openhands.dev/usage/architecture/runtime) documentation.
## Main Components
### 1. base.py
The `base.py` file defines the `Runtime` class, which serves as the primary [interface](./base.py) for agent interactions with the external environment. It handles various operations including:
- Bash sandbox execution
- Browser interactions
- Filesystem operations
- Environment variable management
- Plugin management
Key features of the `Runtime` class:
- Initialization with configuration and event stream
- Asynchronous initialization (`ainit`) for setting up environment variables
- Action execution methods for different types of actions (run, read, write, browse, etc.)
- Abstract methods for file operations (to be implemented by subclasses)
### 2. impl/action_execution/action_execution_client.py
The `action_execution_client.py` file contains the `ActionExecutionClient` class, which implements the Runtime interface. It is an abstract implementation, meaning
it still needs to be extended by a concrete implementation to be used.
This client interacts with an action_execution_server (defined below) via HTTP
calls to actually perform runtime actions.
### 3. action_execution_server.py
The `action_executor_server.py` file contains the `ActionExecutor` class, which is responsible for executing actions received via the `/execute_action` HTTP endpoint. It returns observations in the HTTP response.
Key features of the `ActionExecutor` class:
- Initialization of user environment and bash shell
- Plugin management and initialization
- Execution of various action types (bash commands, IPython cells, file operations, browsing)
- Integration with BrowserEnv for web interactions
### 4. Other Implementations
The `./impl/` directory contains a few different Runtime implementations, all of
which extend the `ActionExecutionClient` class. These implementations
handle the lifecycle of a Docker container or other environment running the
ActionExecutor server.
There are currently four implementations:
* Docker (runs locally in a Docker container)
* Remote (runs via a custom HTTP API for creating, pausing, resuming, and stopping runtimes in a remote environment)
* Modal (uses the Modal API)
* Runloop (uses the Runloop API)
You may also add your own `Runtime` subclass to the classpath and configure it like this:
```toml
runtime = "app.my.CustomRuntime"
```
## Workflow Description
1. **Initialization**:
- The `Runtime` is initialized with configuration and event stream.
- Environment variables are set up using `ainit` method.
- Plugins are loaded and initialized.
2. **Action Handling**:
- The `Runtime` receives actions through the event stream.
- Actions are validated and routed to appropriate execution methods.
3. **Action Execution**:
- Different types of actions are executed:
- Bash commands using `run` method
- IPython cells using `run_ipython` method
- File operations (read/write) using `read` and `write` methods
- Web browsing using `browse` and `browse_interactive` methods
4. **Observation Generation**:
- After action execution, corresponding observations are generated.
- Observations are added to the event stream.
5. **Plugin Integration**:
- Plugins like Jupyter and AgentSkills are initialized and integrated into the runtime.
6. **Sandbox Environment**:
- The `ActionExecutor` sets up a sandboxed environment inside a Docker container.
- User environment and bash shell are initialized.
- Actions received from the OpenHands backend are executed in this sandboxed environment.
7. **Browser Interactions**:
- Web browsing actions are handled using the `BrowserEnv` class.
## Important Notes
- The runtime uses asynchronous programming (asyncio) for efficient execution.
- Environment variables can be added dynamically to both IPython and Bash environments.
- File operations and command executions are abstracted, allowing for different implementations in subclasses.
- The system uses a plugin architecture for extensibility.
- All interactions with the external environment are managed through the Runtime, ensuring a controlled and secure execution environment.
## Runtime Types
### Docker Runtime
The Docker Runtime is designed for local execution using Docker containers:
- Creates and manages a Docker container for each session
- Executes actions within the container
- Supports direct file system access and local resource management
- Ideal for development, testing, and scenarios requiring full control over the execution environment
Key features:
- Real-time logging and debugging capabilities
- Direct access to the local file system
- Faster execution due to local resources
- Container isolation for security
This is the default runtime used within OpenHands.
### Local Runtime
The Local Runtime is designed for direct execution on the local machine. Currently only supports running as the local user:
- Runs the action_execution_server directly on the host
- No Docker container overhead
- Direct access to local system resources
- Ideal for development and testing when Docker is not available or desired
Key features:
- Minimal setup required
- Direct access to local resources
- No container overhead
- Fastest execution speed
**Important: This runtime provides no isolation as it runs directly on the host machine. All actions are executed with the same permissions as the user running OpenHands. For secure execution with proper isolation, use the Docker Runtime instead.**
### Remote Runtime
The Remote Runtime is designed for execution in a remote environment:
- Connects to a remote server running the ActionExecutor
- Executes actions by sending requests to the remote client
- Supports distributed execution and cloud-based deployments
- Ideal for production environments, scalability, and scenarios where local resource constraints are a concern
Key features:
- Scalability and resource flexibility
- Reduced local resource usage
- Support for cloud-based deployments
- Potential for improved security through isolation
At the time of this writing, this is mostly used in parallel evaluation, such as this example for [SWE-Bench](https://github.com/OpenHands/OpenHands/tree/main/evaluation/benchmarks/swe_bench#run-inference-on-remoteruntime-experimental).
## Related Components
- The runtime interacts closely with the event system defined in the `openhands.events` module.
- It relies on configuration classes from `openhands.core.config`.
- Logging is handled through `openhands.core.logger`.
This section provides an overview of the OpenHands Runtime folder. For more detailed information on specific components or usage, please refer to the individual files and their docstrings.
+126
View File
@@ -0,0 +1,126 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
import importlib
from openhands.runtime.base import Runtime
from openhands.runtime.impl.cli.cli_runtime import CLIRuntime
from openhands.runtime.impl.docker.docker_runtime import (
DockerRuntime,
)
from openhands.runtime.impl.kubernetes.kubernetes_runtime import KubernetesRuntime
from openhands.runtime.impl.local.local_runtime import LocalRuntime
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
from openhands.utils.import_utils import get_impl
# mypy: disable-error-code="type-abstract"
_DEFAULT_RUNTIME_CLASSES: dict[str, type[Runtime]] = {
'eventstream': DockerRuntime,
'docker': DockerRuntime,
'remote': RemoteRuntime,
'local': LocalRuntime,
'kubernetes': KubernetesRuntime,
'cli': CLIRuntime,
}
# Try to import third-party runtimes if available
_THIRD_PARTY_RUNTIME_CLASSES: dict[str, type[Runtime]] = {}
# Dynamically discover and import third-party runtimes
# Check if third_party package exists and discover runtimes
try:
import third_party.runtime.impl
third_party_base = 'third_party.runtime.impl'
# List of potential third-party runtime modules to try
# These are discovered from the third_party directory structure
potential_runtimes = []
try:
import pkgutil
for importer, modname, ispkg in pkgutil.iter_modules(
third_party.runtime.impl.__path__
):
if ispkg:
potential_runtimes.append(modname)
except Exception:
# If discovery fails, no third-party runtimes will be loaded
potential_runtimes = []
# Try to import each discovered runtime
for runtime_name in potential_runtimes:
try:
module_path = f'{third_party_base}.{runtime_name}.{runtime_name}_runtime'
module = importlib.import_module(module_path)
# Try different class name patterns
possible_class_names = [
f'{runtime_name.upper()}Runtime', # E2BRuntime
f'{runtime_name.capitalize()}Runtime', # E2bRuntime, DaytonaRuntime, etc.
]
runtime_class = None
for class_name in possible_class_names:
try:
runtime_class = getattr(module, class_name)
break
except AttributeError:
continue
if runtime_class:
_THIRD_PARTY_RUNTIME_CLASSES[runtime_name] = runtime_class
except ImportError:
# ImportError means the library is not installed (expected for optional dependencies)
pass
except Exception as e:
# Other exceptions mean the library is present but broken, which should be logged
from openhands.core.logger import openhands_logger as logger
logger.warning(f'Failed to import third-party runtime {module_path}: {e}')
pass
except ImportError:
# third_party package not available
pass
# Combine core and third-party runtimes
_ALL_RUNTIME_CLASSES = {**_DEFAULT_RUNTIME_CLASSES, **_THIRD_PARTY_RUNTIME_CLASSES}
def get_runtime_cls(name: str) -> type[Runtime]:
"""If name is one of the predefined runtime names (e.g. 'docker'), return its class.
Otherwise attempt to resolve name as subclass of Runtime and return it.
Raise on invalid selections.
"""
if name in _ALL_RUNTIME_CLASSES:
return _ALL_RUNTIME_CLASSES[name]
try:
return get_impl(Runtime, name)
except Exception as e:
known_keys = _ALL_RUNTIME_CLASSES.keys()
raise ValueError(
f'Runtime {name} not supported, known are: {known_keys}'
) from e
# Build __all__ list dynamically based on available runtimes
__all__ = [
'Runtime',
'RemoteRuntime',
'DockerRuntime',
'KubernetesRuntime',
'CLIRuntime',
'LocalRuntime',
'get_runtime_cls',
]
# Add third-party runtimes to __all__ if they're available
for runtime_name, runtime_class in _THIRD_PARTY_RUNTIME_CLASSES.items():
__all__.append(runtime_class.__name__)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
from openhands.runtime.browser.utils import browse
__all__ = ['browse']
+41
View File
@@ -0,0 +1,41 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
import base64
import io
import numpy as np
from PIL import Image
def image_to_png_base64_url(
image: np.ndarray | Image.Image, add_data_prefix: bool = False
) -> str:
"""Convert a numpy array to a base64 encoded png image url."""
if isinstance(image, np.ndarray):
image = Image.fromarray(image)
if image.mode in ('RGBA', 'LA'):
image = image.convert('RGB')
buffered = io.BytesIO()
image.save(buffered, format='PNG')
image_base64 = base64.b64encode(buffered.getvalue()).decode()
return (
f'data:image/png;base64,{image_base64}'
if add_data_prefix
else f'{image_base64}'
)
def png_base64_url_to_image(png_base64_url: str) -> Image.Image:
"""Convert a base64 encoded png image url to a PIL Image."""
splited = png_base64_url.split(',')
if len(splited) == 2:
base64_data = splited[1]
else:
base64_data = png_base64_url
return Image.open(io.BytesIO(base64.b64decode(base64_data)))
+253
View File
@@ -0,0 +1,253 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
import atexit
import json
import multiprocessing
import os
import time
import uuid
from pathlib import Path
import browsergym.core # noqa F401 (we register the openended task as a gym environment)
import gymnasium as gym
import html2text
import tenacity
from browsergym.utils.obs import flatten_dom_to_str, overlay_som
from openhands.core.exceptions import BrowserInitException
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.browser.base64 import image_to_png_base64_url
from openhands.utils.shutdown_listener import should_continue, should_exit
from openhands.utils.tenacity_stop import stop_if_should_exit
BROWSER_EVAL_GET_GOAL_ACTION = 'GET_EVAL_GOAL'
BROWSER_EVAL_GET_REWARDS_ACTION = 'GET_EVAL_REWARDS'
class BrowserEnv:
def __init__(self, browsergym_eval_env: str | None = None):
self.html_text_converter = self.get_html_text_converter()
self.eval_mode = False
self.eval_dir = ''
# EVAL only: browsergym_eval_env must be provided for evaluation
self.browsergym_eval_env = browsergym_eval_env
self.eval_mode = bool(browsergym_eval_env)
# Initialize browser environment process
multiprocessing.set_start_method('spawn', force=True)
self.browser_side, self.agent_side = multiprocessing.Pipe()
self.init_browser()
atexit.register(self.close)
def get_html_text_converter(self) -> html2text.HTML2Text:
html_text_converter = html2text.HTML2Text()
# ignore links and images
html_text_converter.ignore_links = False
html_text_converter.ignore_images = True
# use alt text for images
html_text_converter.images_to_alt = True
# disable auto text wrapping
html_text_converter.body_width = 0
return html_text_converter
@tenacity.retry(
wait=tenacity.wait_fixed(1),
stop=tenacity.stop_after_attempt(5) | stop_if_should_exit(),
retry=tenacity.retry_if_exception_type(BrowserInitException),
)
def init_browser(self) -> None:
logger.debug('Starting browser env...')
try:
self.process = multiprocessing.Process(target=self.browser_process)
self.process.start()
except Exception as e:
logger.error(f'Failed to start browser process: {e}')
raise
if not self.check_alive(timeout=200):
self.close()
raise BrowserInitException('Failed to start browser environment.')
def browser_process(self) -> None:
def _is_local_runtime() -> bool:
runtime_flag = os.getenv('RUNTIME', '').lower()
return runtime_flag == 'local'
# Default Playwright cache for local runs only; do not override in docker
if _is_local_runtime() and 'PLAYWRIGHT_BROWSERS_PATH' not in os.environ:
os.environ['PLAYWRIGHT_BROWSERS_PATH'] = str(
Path.home() / '.cache' / 'playwright'
)
if self.eval_mode:
assert self.browsergym_eval_env is not None
logger.info('Initializing browser env for web browsing evaluation.')
if not self.browsergym_eval_env.startswith('browsergym/'):
self.browsergym_eval_env = 'browsergym/' + self.browsergym_eval_env
if 'visualwebarena' in self.browsergym_eval_env:
import browsergym.visualwebarena # noqa F401 register visualwebarena tasks as gym environments
import nltk
nltk.download('punkt_tab')
elif 'webarena' in self.browsergym_eval_env:
import browsergym.webarena # noqa F401 register webarena tasks as gym environments
elif 'miniwob' in self.browsergym_eval_env:
import browsergym.miniwob # noqa F401 register miniwob tasks as gym environments
else:
raise ValueError(
f'Unsupported browsergym eval env: {self.browsergym_eval_env}'
)
env = gym.make(self.browsergym_eval_env, tags_to_mark='all', timeout=100000)
else:
downloads_path = os.getenv('BROWSERGYM_DOWNLOAD_DIR')
if not downloads_path and _is_local_runtime():
downloads_path = str(Path.home() / '.cache' / 'browsergym-downloads')
if not downloads_path:
downloads_path = '/workspace/.downloads/'
env = gym.make(
'browsergym/openended',
task_kwargs={'start_url': 'about:blank', 'goal': 'PLACEHOLDER_GOAL'},
wait_for_user_message=False,
headless=True,
disable_env_checker=True,
tags_to_mark='all',
timeout=100000,
pw_context_kwargs={'accept_downloads': True},
pw_chromium_kwargs={'downloads_path': downloads_path},
)
obs, info = env.reset()
logger.info('Successfully called env.reset')
# EVAL ONLY: save the goal into file for evaluation
self.eval_goal = None
self.goal_image_urls = []
self.eval_rewards: list[float] = []
if self.eval_mode:
self.eval_goal = obs['goal']
if 'goal_object' in obs:
obs['goal_object'] = list(obs['goal_object'])
if len(obs['goal_object']) > 0:
self.eval_goal = obs['goal_object'][0]['text']
for message in obs['goal_object']:
if message['type'] == 'image_url':
image_src = message['image_url']
if isinstance(image_src, dict):
image_src = image_src['url']
self.goal_image_urls.append(image_src)
logger.debug(f'Browsing goal: {self.eval_goal}')
logger.info('Browser env started.')
while should_continue():
try:
if self.browser_side.poll(timeout=0.01):
unique_request_id, action_data = self.browser_side.recv()
# shutdown the browser environment
if unique_request_id == 'SHUTDOWN':
logger.debug('SHUTDOWN recv, shutting down browser env...')
env.close()
return
elif unique_request_id == 'IS_ALIVE':
self.browser_side.send(('ALIVE', None))
continue
# EVAL ONLY: Get evaluation info
if action_data['action'] == BROWSER_EVAL_GET_GOAL_ACTION:
self.browser_side.send(
(
unique_request_id,
{
'text_content': self.eval_goal,
'image_content': self.goal_image_urls,
},
)
)
continue
elif action_data['action'] == BROWSER_EVAL_GET_REWARDS_ACTION:
self.browser_side.send(
(
unique_request_id,
{'text_content': json.dumps(self.eval_rewards)},
)
)
continue
action = action_data['action']
obs, reward, terminated, truncated, info = env.step(action)
# EVAL ONLY: Save the rewards into file for evaluation
if self.eval_mode:
self.eval_rewards.append(reward)
# add text content of the page
html_str = flatten_dom_to_str(obs['dom_object'])
obs['text_content'] = self.html_text_converter.handle(html_str)
# make observation serializable
obs['set_of_marks'] = image_to_png_base64_url(
overlay_som(
obs['screenshot'], obs.get('extra_element_properties', {})
),
add_data_prefix=True,
)
obs['screenshot'] = image_to_png_base64_url(
obs['screenshot'], add_data_prefix=True
)
obs['active_page_index'] = obs['active_page_index'].item()
obs['elapsed_time'] = obs['elapsed_time'].item()
self.browser_side.send((unique_request_id, obs))
except KeyboardInterrupt:
logger.debug('Browser env process interrupted by user.')
try:
env.close()
except Exception:
pass
return
def step(self, action_str: str, timeout: float = 120) -> dict:
"""Execute an action in the browser environment and return the observation."""
unique_request_id = str(uuid.uuid4())
self.agent_side.send((unique_request_id, {'action': action_str}))
start_time = time.time()
while True:
if should_exit() or time.time() - start_time > timeout:
raise TimeoutError('Browser environment took too long to respond.')
if self.agent_side.poll(timeout=0.01):
response_id, obs = self.agent_side.recv()
if response_id == unique_request_id:
return dict(obs)
def check_alive(self, timeout: float = 60) -> bool:
self.agent_side.send(('IS_ALIVE', None))
if self.agent_side.poll(timeout=timeout):
response_id, _ = self.agent_side.recv()
if response_id == 'ALIVE':
return True
logger.debug(f'Browser env is not alive. Response ID: {response_id}')
return False
def close(self) -> None:
if not self.process.is_alive():
return
try:
self.agent_side.send(('SHUTDOWN', None))
self.process.join(5) # Wait for the process to terminate
if self.process.is_alive():
logger.error(
'Browser process did not terminate, forcefully terminating...'
)
self.process.terminate()
self.process.join(5) # Wait for the process to terminate
if self.process.is_alive():
self.process.kill()
self.process.join(5) # Wait for the process to terminate
self.agent_side.close()
self.browser_side.close()
except Exception as e:
logger.error(f'Encountered an error when closing browser env: {e}')
+230
View File
@@ -0,0 +1,230 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
import base64
import datetime
import os
from pathlib import Path
from typing import Any
from browsergym.utils.obs import flatten_axtree_to_str
from PIL import Image
from openhands.core.exceptions import BrowserUnavailableException
from openhands.core.schema import ActionType
from openhands.events.action import BrowseInteractiveAction, BrowseURLAction
from openhands.events.observation import BrowserOutputObservation
from openhands.runtime.browser.base64 import png_base64_url_to_image
from openhands.runtime.browser.browser_env import BrowserEnv
from openhands.utils.async_utils import call_sync_from_async
def get_axtree_str(
axtree_object: dict[str, Any],
extra_element_properties: dict[str, Any],
filter_visible_only: bool = False,
) -> str:
cur_axtree_txt = flatten_axtree_to_str(
axtree_object,
extra_properties=extra_element_properties,
with_clickable=True,
skip_generic=False,
filter_visible_only=filter_visible_only,
)
return str(cur_axtree_txt)
def get_agent_obs_text(obs: BrowserOutputObservation) -> str:
"""Get a concise text that will be shown to the agent."""
if obs.trigger_by_action == ActionType.BROWSE_INTERACTIVE:
text = f'[Current URL: {obs.url}]\n'
text += f'[Focused element bid: {obs.focused_element_bid}]\n'
# Add screenshot path information if available
if obs.screenshot_path:
text += f'[Screenshot saved to: {obs.screenshot_path}]\n'
text += '\n'
if obs.error:
text += (
'================ BEGIN error message ===============\n'
'The following error occurred when executing the last action:\n'
f'{obs.last_browser_action_error}\n'
'================ END error message ===============\n'
)
else:
text += '[Action executed successfully.]\n'
try:
# We do not filter visible only here because we want to show the full content
# of the web page to the agent for simplicity.
# FIXME: handle the case when the web page is too large
cur_axtree_txt = get_axtree_str(
obs.axtree_object,
obs.extra_element_properties,
filter_visible_only=obs.filter_visible_only,
)
if not obs.filter_visible_only:
text += (
f'Accessibility tree of the COMPLETE webpage:\nNote: [bid] is the unique alpha-numeric identifier at the beginning of lines for each element in the AXTree. Always use bid to refer to elements in your actions.\n'
f'============== BEGIN accessibility tree ==============\n'
f'{cur_axtree_txt}\n'
f'============== END accessibility tree ==============\n'
)
else:
text += (
f'Accessibility tree of the VISIBLE portion of the webpage (accessibility tree of complete webpage is too large and you may need to scroll to view remaining portion of the webpage):\nNote: [bid] is the unique alpha-numeric identifier at the beginning of lines for each element in the AXTree. Always use bid to refer to elements in your actions.\n'
f'============== BEGIN accessibility tree ==============\n'
f'{cur_axtree_txt}\n'
f'============== END accessibility tree ==============\n'
)
except Exception as e:
text += f'\n[Error encountered when processing the accessibility tree: {e}]'
return text
elif obs.trigger_by_action == ActionType.BROWSE:
text = f'[Current URL: {obs.url}]\n'
if obs.error:
text += (
'================ BEGIN error message ===============\n'
'The following error occurred when trying to visit the URL:\n'
f'{obs.last_browser_action_error}\n'
'================ END error message ===============\n'
)
text += '============== BEGIN webpage content ==============\n'
text += obs.content
text += '\n============== END webpage content ==============\n'
return text
else:
raise ValueError(f'Invalid trigger_by_action: {obs.trigger_by_action}')
async def browse(
action: BrowseURLAction | BrowseInteractiveAction,
browser: BrowserEnv | None,
workspace_dir: str | None = None,
) -> BrowserOutputObservation:
if browser is None:
raise BrowserUnavailableException()
if isinstance(action, BrowseURLAction):
# legacy BrowseURLAction
asked_url = action.url
if not asked_url.startswith('http'):
asked_url = os.path.abspath(os.curdir) + action.url
action_str = f'goto("{asked_url}")'
elif isinstance(action, BrowseInteractiveAction):
# new BrowseInteractiveAction, supports full featured BrowserGym actions
# action in BrowserGym: see https://github.com/ServiceNow/BrowserGym/blob/main/core/src/browsergym/core/action/functions.py
action_str = action.browser_actions
else:
raise ValueError(f'Invalid action type: {action.action}')
try:
# obs provided by BrowserGym: see https://github.com/ServiceNow/BrowserGym/blob/main/core/src/browsergym/core/env.py#L396
obs = await call_sync_from_async(browser.step, action_str)
# Save screenshot if workspace_dir is provided
screenshot_path = None
if workspace_dir is not None and obs.get('screenshot'):
# Create screenshots directory if it doesn't exist
screenshots_dir = Path(workspace_dir) / '.browser_screenshots'
screenshots_dir.mkdir(exist_ok=True)
# Generate a filename based on timestamp
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S_%f')
screenshot_filename = f'screenshot_{timestamp}.png'
screenshot_path = str(screenshots_dir / screenshot_filename)
# Direct image saving from base64 data without using PIL's Image.open
# This approach bypasses potential encoding issues that might occur when
# converting between different image representations, ensuring the raw PNG
# data from the browser is saved directly to disk.
# Extract the base64 data
base64_data = obs.get('screenshot', '')
if ',' in base64_data:
base64_data = base64_data.split(',')[1]
try:
# Decode base64 directly to binary
image_data = base64.b64decode(base64_data)
# Write binary data directly to file
with open(screenshot_path, 'wb') as f:
f.write(image_data)
# Verify the image was saved correctly by opening it
# This is just a verification step and can be removed in production
Image.open(screenshot_path).verify()
except Exception:
# If direct saving fails, fall back to the original method
image = png_base64_url_to_image(obs.get('screenshot'))
image.save(screenshot_path, format='PNG', optimize=True)
# Create the observation with all data
observation = BrowserOutputObservation(
content=obs['text_content'], # text content of the page
url=obs.get('url', ''), # URL of the page
screenshot=obs.get('screenshot', None), # base64-encoded screenshot, png
screenshot_path=screenshot_path, # path to saved screenshot file
set_of_marks=obs.get(
'set_of_marks', None
), # base64-encoded Set-of-Marks annotated screenshot, png,
goal_image_urls=obs.get('image_content', []),
open_pages_urls=obs.get('open_pages_urls', []), # list of open pages
active_page_index=obs.get(
'active_page_index', -1
), # index of the active page
axtree_object=obs.get('axtree_object', {}), # accessibility tree object
extra_element_properties=obs.get('extra_element_properties', {}),
focused_element_bid=obs.get(
'focused_element_bid', None
), # focused element bid
last_browser_action=obs.get(
'last_action', ''
), # last browser env action performed
last_browser_action_error=obs.get('last_action_error', ''),
error=True if obs.get('last_action_error', '') else False, # error flag
trigger_by_action=action.action,
)
# Process the content first using the axtree_object
observation.content = get_agent_obs_text(observation)
# If return_axtree is False, remove the axtree_object to save space
if not action.return_axtree:
observation.dom_object = {}
observation.axtree_object = {}
observation.extra_element_properties = {}
return observation
except Exception as e:
error_message = str(e)
error_url = asked_url if action.action == ActionType.BROWSE else ''
# Create error observation
observation = BrowserOutputObservation(
content=error_message,
screenshot='',
screenshot_path=None,
error=True,
last_browser_action_error=error_message,
url=error_url,
trigger_by_action=action.action,
)
# Process the content using get_agent_obs_text regardless of return_axtree value
try:
observation.content = get_agent_obs_text(observation)
except Exception:
# If get_agent_obs_text fails, keep the original error message
pass
return observation
+11
View File
@@ -0,0 +1,11 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
from openhands.runtime.builder.base import RuntimeBuilder
from openhands.runtime.builder.docker import DockerRuntimeBuilder
__all__ = ['RuntimeBuilder', 'DockerRuntimeBuilder']
+49
View File
@@ -0,0 +1,49 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
import abc
class RuntimeBuilder(abc.ABC):
@abc.abstractmethod
def build(
self,
path: str,
tags: list[str],
platform: str | None = None,
extra_build_args: list[str] | None = None,
) -> str:
"""Build the runtime image.
Args:
path (str): The path to the runtime image's build directory.
tags (list[str]): The tags to apply to the runtime image (e.g., ["repo:my-repo", "sha:my-sha"]).
platform (str, optional): The target platform for the build. Defaults to None.
extra_build_args (list[str], optional): Additional build arguments to pass to the builder. Defaults to None.
Returns:
str: The name:tag of the runtime image after build (e.g., "repo:sha").
This can be different from the tags input if the builder chooses to mutate the tags (e.g., adding a
registry prefix). This should be used for subsequent use (e.g., `docker run`).
Raises:
AgentRuntimeBuildError: If the build failed.
"""
pass
@abc.abstractmethod
def image_exists(self, image_name: str, pull_from_repo: bool = True) -> bool:
"""Check if the runtime image exists.
Args:
image_name (str): The name of the runtime image (e.g., "repo:sha").
pull_from_repo (bool): Whether to pull from the remote repo if the image not present locally
Returns:
bool: Whether the runtime image exists.
"""
pass
+434
View File
@@ -0,0 +1,434 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
import datetime
import os
import subprocess
import time
import docker
from openhands.core.exceptions import AgentRuntimeBuildError
from openhands.core.logger import RollingLogger
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.builder.base import RuntimeBuilder
from openhands.utils.term_color import TermColor, colorize
from openhands.version import get_version
class DockerRuntimeBuilder(RuntimeBuilder):
def __init__(self, docker_client: docker.DockerClient):
self.docker_client = docker_client
version_info = self.docker_client.version()
server_version = version_info.get('Version', '').replace('-', '.')
components = version_info.get('Components')
self.is_podman = (
components is not None
and len(components) > 0
and components[0].get('Name', '').startswith('Podman')
)
if (
tuple(map(int, server_version.split('.')[:2])) < (18, 9)
and not self.is_podman
):
raise AgentRuntimeBuildError(
'Docker server version must be >= 18.09 to use BuildKit'
)
if self.is_podman and tuple(map(int, server_version.split('.')[:2])) < (4, 9):
raise AgentRuntimeBuildError('Podman server version must be >= 4.9.0')
self.rolling_logger = RollingLogger(max_lines=10)
@staticmethod
def check_buildx(is_podman: bool = False) -> bool:
"""Check if Docker Buildx is available."""
try:
result = subprocess.run(
['docker' if not is_podman else 'podman', 'buildx', 'version'],
capture_output=True,
text=True,
)
return result.returncode == 0
except FileNotFoundError:
return False
def build(
self,
path: str,
tags: list[str],
platform: str | None = None,
extra_build_args: list[str] | None = None,
use_local_cache: bool = False,
) -> str:
"""Builds a Docker image using BuildKit and handles the build logs appropriately.
Args:
path (str): The path to the Docker build context.
tags (list[str]): A list of image tags to apply to the built image.
platform (str, optional): The target platform for the build. Defaults to None.
use_local_cache (bool, optional): Whether to use and update the local build cache. Defaults to True.
extra_build_args (list[str], optional): Additional arguments to pass to the Docker build command. Defaults to None.
Returns:
str: The name of the built Docker image.
Raises:
AgentRuntimeBuildError: If the Docker server version is incompatible or if the build process fails.
Note:
This method uses Docker BuildKit for improved build performance and caching capabilities.
If `use_local_cache` is True, it will attempt to use and update the build cache in a local directory.
The `extra_build_args` parameter allows for passing additional Docker build arguments as needed.
"""
self.docker_client = docker.from_env()
version_info = self.docker_client.version()
server_version = version_info.get('Version', '').split('+')[0].replace('-', '.')
components = version_info.get('Components')
self.is_podman = (
components is not None
and len(components) > 0
and components[0].get('Name', '').startswith('Podman')
)
if tuple(map(int, server_version.split('.'))) < (18, 9) and not self.is_podman:
raise AgentRuntimeBuildError(
'Docker server version must be >= 18.09 to use BuildKit'
)
if self.is_podman and tuple(map(int, server_version.split('.'))) < (4, 9):
raise AgentRuntimeBuildError('Podman server version must be >= 4.9.0')
if not DockerRuntimeBuilder.check_buildx(self.is_podman):
# when running openhands in a container, there might not be a "docker"
# binary available, in which case we need to download docker binary.
# since the official openhands app image is built from debian, we use
# debian way to install docker binary
logger.info(
'No docker binary available inside openhands-app container, trying to download online...'
)
commands = [
'apt-get update',
'apt-get install -y ca-certificates curl gnupg',
'install -m 0755 -d /etc/apt/keyrings',
'curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc',
'chmod a+r /etc/apt/keyrings/docker.asc',
'echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
tee /etc/apt/sources.list.d/docker.list > /dev/null',
'apt-get update',
'apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin',
]
for cmd in commands:
try:
subprocess.run(
cmd, shell=True, check=True, stdout=subprocess.DEVNULL
)
except subprocess.CalledProcessError as e:
logger.error(f'Image build failed:\n{e}')
logger.error(f'Command output:\n{e.output}')
raise
logger.info('Downloaded and installed docker binary')
target_image_hash_name = tags[0]
target_image_repo, target_image_source_tag = target_image_hash_name.split(':')
target_image_tag = tags[1].split(':')[1] if len(tags) > 1 else None
buildx_cmd = [
'docker' if not self.is_podman else 'podman',
'buildx',
'build',
'--progress=plain',
f'--build-arg=OPENHANDS_RUNTIME_VERSION={get_version()}',
f'--build-arg=OPENHANDS_RUNTIME_BUILD_TIME={datetime.datetime.now().isoformat()}',
f'--tag={target_image_hash_name}',
'--load',
]
# Include the platform argument only if platform is specified
if platform:
buildx_cmd.append(f'--platform={platform}')
cache_dir = '/tmp/.buildx-cache'
if use_local_cache and self._is_cache_usable(cache_dir):
buildx_cmd.extend(
[
f'--cache-from=type=local,src={cache_dir}',
f'--cache-to=type=local,dest={cache_dir},mode=max',
]
)
if extra_build_args:
buildx_cmd.extend(extra_build_args)
buildx_cmd.append(path) # must be last!
self.rolling_logger.start(
f'================ {buildx_cmd[0].upper()} BUILD STARTED ================'
)
builder_cmd = ['docker', 'buildx', 'use', 'default']
subprocess.Popen(
builder_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
)
try:
process = subprocess.Popen(
buildx_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
bufsize=1,
)
output_lines = []
if process.stdout:
for line in iter(process.stdout.readline, ''):
line = line.strip()
if line:
output_lines.append(line) # Store all output lines
self._output_logs(line)
return_code = process.wait()
if return_code != 0:
# Use the collected output for error reporting
output_str = '\n'.join(output_lines)
raise subprocess.CalledProcessError(
return_code,
process.args,
output=output_str, # Use the collected output
stderr=None,
)
except subprocess.CalledProcessError as e:
logger.error(f'Image build failed with exit code {e.returncode}')
if e.output:
logger.error(f'Command output:\n{e.output}')
elif self.rolling_logger.is_enabled() and self.rolling_logger.all_lines:
logger.error(f'Docker build output:\n{self.rolling_logger.all_lines}')
raise
except subprocess.TimeoutExpired:
logger.error('Image build timed out')
raise
except FileNotFoundError as e:
logger.error(f'Python executable not found: {e}')
raise
except PermissionError as e:
logger.error(
f'Permission denied when trying to execute the build command:\n{e}'
)
raise
except Exception as e:
logger.error(f'An unexpected error occurred during the build process: {e}')
raise
logger.info(f'Image [{target_image_hash_name}] build finished.')
if target_image_tag:
image = self.docker_client.images.get(target_image_hash_name)
image.tag(target_image_repo, target_image_tag)
logger.info(
f'Re-tagged image [{target_image_hash_name}] with more generic tag [{target_image_tag}]'
)
# Check if the image is built successfully
image = self.docker_client.images.get(target_image_hash_name)
if image is None:
raise AgentRuntimeBuildError(
f'Build failed: Image {target_image_hash_name} not found'
)
tags_str = (
f'{target_image_source_tag}, {target_image_tag}'
if target_image_tag
else target_image_source_tag
)
logger.info(
f'Image {target_image_repo} with tags [{tags_str}] built successfully'
)
return target_image_hash_name
def image_exists(self, image_name: str, pull_from_repo: bool = True) -> bool:
"""Check if the image exists in the registry (try to pull it first) or in the local store.
Args:
image_name (str): The Docker image to check (<image repo>:<image tag>)
pull_from_repo (bool): Whether to pull from the remote repo if the image not present locally
Returns:
bool: Whether the Docker image exists in the registry or in the local store
"""
if not image_name:
logger.error(f'Invalid image name: `{image_name}`')
return False
try:
logger.debug(f'Checking, if image exists locally:\n{image_name}')
self.docker_client.images.get(image_name)
logger.debug('Image found locally.')
return True
except docker.errors.ImageNotFound:
if not pull_from_repo:
logger.debug(
f'Image {image_name} {colorize("not found", TermColor.WARNING)} locally'
)
return False
try:
logger.debug(
'Image not found locally. Trying to pull it, please wait...'
)
layers: dict[str, dict[str, str]] = {}
previous_layer_count = 0
if ':' in image_name:
image_repo, image_tag = image_name.split(':', 1)
else:
image_repo = image_name
image_tag = None
for line in self.docker_client.api.pull(
image_repo, tag=image_tag, stream=True, decode=True
):
self._output_build_progress(line, layers, previous_layer_count)
previous_layer_count = len(layers)
logger.debug('Image pulled')
return True
except docker.errors.ImageNotFound:
logger.debug('Could not find image locally or in registry.')
return False
except Exception as e:
msg = f'Image {colorize("could not be pulled", TermColor.ERROR)}: '
ex_msg = str(e)
if 'Not Found' in ex_msg:
msg += 'image not found in registry.'
else:
msg += f'{ex_msg}'
logger.debug(msg)
return False
def _output_logs(self, new_line: str) -> None:
if not self.rolling_logger.is_enabled():
logger.debug(new_line)
else:
self.rolling_logger.add_line(new_line)
def _output_build_progress(
self, current_line: dict, layers: dict, previous_layer_count: int
) -> None:
if 'id' in current_line and 'progressDetail' in current_line:
layer_id = current_line['id']
if layer_id not in layers:
layers[layer_id] = {'status': '', 'progress': '', 'last_logged': 0}
if 'status' in current_line:
layers[layer_id]['status'] = current_line['status']
if 'progress' in current_line:
layers[layer_id]['progress'] = current_line['progress']
if 'progressDetail' in current_line:
progress_detail = current_line['progressDetail']
if 'total' in progress_detail and 'current' in progress_detail:
total = progress_detail['total']
current = progress_detail['current']
percentage = min(
(current / total) * 100, 100
) # Ensure it doesn't exceed 100%
else:
percentage = (
100 if layers[layer_id]['status'] == 'Download complete' else 0
)
if self.rolling_logger.is_enabled():
self.rolling_logger.move_back(previous_layer_count)
for lid, layer_data in sorted(layers.items()):
self.rolling_logger.replace_current_line()
status = layer_data['status']
progress = layer_data['progress']
if status == 'Download complete':
self.rolling_logger.write_immediately(
f'Layer {lid}: Download complete'
)
elif status == 'Already exists':
self.rolling_logger.write_immediately(
f'Layer {lid}: Already exists'
)
else:
self.rolling_logger.write_immediately(
f'Layer {lid}: {progress} {status}'
)
elif percentage != 0 and (
percentage - layers[layer_id]['last_logged'] >= 10 or percentage == 100
):
logger.debug(
f'Layer {layer_id}: {layers[layer_id]["progress"]} {layers[layer_id]["status"]}'
)
layers[layer_id]['last_logged'] = percentage
elif 'status' in current_line:
logger.debug(current_line['status'])
def _prune_old_cache_files(self, cache_dir: str, max_age_days: int = 7) -> None:
"""Prune cache files older than the specified number of days.
Args:
cache_dir (str): The path to the cache directory.
max_age_days (int): The maximum age of cache files in days.
"""
try:
current_time = time.time()
max_age_seconds = max_age_days * 24 * 60 * 60
for root, _, files in os.walk(cache_dir):
for file in files:
file_path = os.path.join(root, file)
try:
file_age = current_time - os.path.getmtime(file_path)
if file_age > max_age_seconds:
os.remove(file_path)
logger.debug(f'Removed old cache file: {file_path}')
except Exception as e:
logger.warning(f'Error processing cache file {file_path}: {e}')
except Exception as e:
logger.warning(f'Error during build cache pruning: {e}')
def _is_cache_usable(self, cache_dir: str) -> bool:
"""Check if the cache directory is usable (exists and is writable).
Args:
cache_dir (str): The path to the cache directory.
Returns:
bool: True if the cache directory is usable, False otherwise.
"""
if not os.path.exists(cache_dir):
try:
os.makedirs(cache_dir, exist_ok=True)
logger.debug(f'Created cache directory: {cache_dir}')
except OSError as e:
logger.debug(f'Failed to create cache directory {cache_dir}: {e}')
return False
if not os.access(cache_dir, os.W_OK):
logger.warning(
f'Cache directory {cache_dir} is not writable. Caches will not be used for Docker builds.'
)
return False
self._prune_old_cache_files(cache_dir)
logger.debug(f'Cache directory {cache_dir} is usable')
return True
+156
View File
@@ -0,0 +1,156 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
import base64
import io
import tarfile
import time
import httpx
from openhands.core.exceptions import AgentRuntimeBuildError
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.builder import RuntimeBuilder
from openhands.runtime.utils.request import send_request
from openhands.utils.http_session import HttpSession
from openhands.utils.shutdown_listener import (
should_continue,
sleep_if_should_continue,
)
class RemoteRuntimeBuilder(RuntimeBuilder):
"""This class interacts with the remote Runtime API for building and managing container images."""
def __init__(self, api_url: str, api_key: str, session: HttpSession | None = None):
self.api_url = api_url
self.api_key = api_key
self.session = session or HttpSession()
self.session.headers.update({'X-API-Key': self.api_key})
def build(
self,
path: str,
tags: list[str],
platform: str | None = None,
extra_build_args: list[str] | None = None,
) -> str:
"""Builds a Docker image using the Runtime API's /build endpoint."""
# Create a tar archive of the build context
tar_buffer = io.BytesIO()
with tarfile.open(fileobj=tar_buffer, mode='w:gz') as tar:
tar.add(path, arcname='.')
tar_buffer.seek(0)
# Encode the tar file as base64
base64_encoded_tar = base64.b64encode(tar_buffer.getvalue()).decode('utf-8')
# Prepare the multipart form data
files = [
('context', ('context.tar.gz', base64_encoded_tar)),
('target_image', (None, tags[0])),
]
# Add additional tags if present
for tag in tags[1:]:
files.append(('tags', (None, tag)))
# Send the POST request to /build (Begins the build process)
try:
response = send_request(
self.session,
'POST',
f'{self.api_url}/build',
files=files,
timeout=30,
)
except httpx.HTTPStatusError as e:
if e.response and e.response.status_code == 429:
logger.warning('Build was rate limited. Retrying in 30 seconds.')
time.sleep(30)
return self.build(path, tags, platform)
else:
raise e
build_data = response.json()
build_id = build_data['build_id']
logger.info(f'Build initiated with ID: {build_id}')
# Poll /build_status until the build is complete
start_time = time.time()
timeout = 30 * 60 # 20 minutes in seconds
while should_continue():
if time.time() - start_time > timeout:
logger.error('Build timed out after 30 minutes')
raise AgentRuntimeBuildError('Build timed out after 30 minutes')
status_response = send_request(
self.session,
'GET',
f'{self.api_url}/build_status',
params={'build_id': build_id},
)
if status_response.status_code != 200:
logger.error(f'Failed to get build status: {status_response.text}')
raise AgentRuntimeBuildError(
f'Failed to get build status: {status_response.text}'
)
status_data = status_response.json()
status = status_data['status']
logger.info(f'Build status: {status}')
if status == 'SUCCESS':
logger.debug(f'Successfully built {status_data["image"]}')
return str(status_data['image'])
elif status in [
'FAILURE',
'INTERNAL_ERROR',
'TIMEOUT',
'CANCELLED',
'EXPIRED',
]:
error_message = status_data.get(
'error', f'Build failed with status: {status}. Build ID: {build_id}'
)
logger.error(error_message)
raise AgentRuntimeBuildError(error_message)
# Wait before polling again
sleep_if_should_continue(30)
raise AgentRuntimeBuildError('Build interrupted')
def image_exists(self, image_name: str, pull_from_repo: bool = True) -> bool:
"""Checks if an image exists in the remote registry using the /image_exists endpoint."""
params = {'image': image_name}
response = send_request(
self.session,
'GET',
f'{self.api_url}/image_exists',
params=params,
)
if response.status_code != 200:
logger.error(f'Failed to check image existence: {response.text}')
raise AgentRuntimeBuildError(
f'Failed to check image existence: {response.text}'
)
result = response.json()
if result['exists']:
logger.debug(
f'Image {image_name} exists. '
f'Uploaded at: {result["image"]["upload_time"]}, '
f'Size: {result["image"]["image_size_bytes"] / 1024 / 1024:.2f} MB'
)
else:
logger.debug(f'Image {image_name} does not exist.')
return bool(result['exists'])
+119
View File
@@ -0,0 +1,119 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
"""A tiny, isolated server that provides only the /view endpoint from the action execution server.
This server has no authentication and only listens to localhost traffic.
"""
import os
import threading
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from uvicorn import Config, Server
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.utils.file_viewer import generate_file_viewer_html
def create_app() -> FastAPI:
"""Create the FastAPI application."""
app = FastAPI(
title='File Viewer Server', openapi_url=None, docs_url=None, redoc_url=None
)
@app.get('/')
async def root() -> dict[str, str]:
"""Root endpoint to check if the server is running."""
return {'status': 'File viewer server is running'}
@app.get('/view')
async def view_file(path: str, request: Request) -> HTMLResponse:
"""View a file using an embedded viewer.
Args:
path (str): The absolute path of the file to view.
request (Request): The FastAPI request object.
Returns:
HTMLResponse: An HTML page with an appropriate viewer for the file.
"""
# Security check: Only allow requests from localhost
client_host = request.client.host if request.client else None
if client_host not in ['127.0.0.1', 'localhost', '::1']:
return HTMLResponse(
content='<h1>Access Denied</h1><p>This endpoint is only accessible from localhost</p>',
status_code=403,
)
if not os.path.isabs(path):
return HTMLResponse(
content=f'<h1>Error: Path must be absolute</h1><p>{path}</p>',
status_code=400,
)
if not os.path.exists(path):
return HTMLResponse(
content=f'<h1>Error: File not found</h1><p>{path}</p>', status_code=404
)
if os.path.isdir(path):
return HTMLResponse(
content=f'<h1>Error: Path is a directory</h1><p>{path}</p>',
status_code=400,
)
try:
html_content = generate_file_viewer_html(path)
return HTMLResponse(content=html_content)
except Exception as e:
return HTMLResponse(
content=f'<h1>Error viewing file</h1><p>{path}</p><p>{str(e)}</p>',
status_code=500,
)
return app
def start_file_viewer_server(port: int) -> tuple[str, threading.Thread]:
"""Start the file viewer server on the specified port or find an available one.
Args:
port (int, optional): The port to bind to. If None, an available port will be found.
Returns:
Tuple[str, threading.Thread]: The server URL and the thread object.
"""
# Save the server URL to a file
server_url = f'http://localhost:{port}'
port_path = '/tmp/oh-server-url'
os.makedirs(os.path.dirname(port_path), exist_ok=True)
with open(port_path, 'w') as f:
f.write(server_url)
logger.info(f'File viewer server URL saved to /tmp/oh-server-url: {server_url}')
logger.info(f'Starting file viewer server on port {port}')
app = create_app()
config = Config(app=app, host='127.0.0.1', port=port, log_level='error')
server = Server(config=config)
# Run the server in a new thread
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
return server_url, thread
if __name__ == '__main__':
url, thread = start_file_viewer_server(port=8000)
# Keep the main thread running
try:
thread.join()
except KeyboardInterrupt:
logger.info('Server stopped')
+24
View File
@@ -0,0 +1,24 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
"""Runtime implementations for OpenHands."""
from openhands.runtime.impl.action_execution.action_execution_client import (
ActionExecutionClient,
)
from openhands.runtime.impl.cli import CLIRuntime
from openhands.runtime.impl.docker.docker_runtime import DockerRuntime
from openhands.runtime.impl.local.local_runtime import LocalRuntime
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
__all__ = [
'ActionExecutionClient',
'CLIRuntime',
'DockerRuntime',
'LocalRuntime',
'RemoteRuntime',
]
@@ -0,0 +1,489 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
import os
import tempfile
import threading
from pathlib import Path
from typing import Any
from zipfile import ZipFile
import httpcore
import httpx
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
from openhands.app_server.status.system_stats import update_last_execution_time
from openhands.core.config import OpenHandsConfig
from openhands.core.config.mcp_config import (
MCPConfig,
RemoteMCPServer,
StdioMCPServer,
)
from openhands.core.exceptions import (
AgentRuntimeTimeoutError,
)
from openhands.events import EventStream
from openhands.events.action import (
ActionConfirmationStatus,
AgentThinkAction,
BrowseInteractiveAction,
BrowseURLAction,
CmdRunAction,
FileEditAction,
FileReadAction,
FileWriteAction,
IPythonRunCellAction,
)
from openhands.events.action.action import Action
from openhands.events.action.files import FileEditSource
from openhands.events.action.mcp import MCPAction
from openhands.events.observation import (
AgentThinkObservation,
ErrorObservation,
NullObservation,
Observation,
UserRejectObservation,
)
from openhands.events.serialization import event_to_dict, observation_from_dict
from openhands.events.serialization.action import ACTION_TYPE_TO_CLASS
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.llm.llm_registry import LLMRegistry
from openhands.runtime.base import Runtime
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.utils.request import send_request
from openhands.utils.http_session import HttpSession
from openhands.utils.tenacity_stop import stop_if_should_exit
def _is_retryable_error(exception):
return isinstance(
exception, (httpx.RemoteProtocolError, httpcore.RemoteProtocolError)
)
class ActionExecutionClient(Runtime):
"""Base class for runtimes that interact with the action execution server.
This class contains shared logic between DockerRuntime and RemoteRuntime
for interacting with the HTTP server defined in action_execution_server.py.
"""
def __init__(
self,
config: OpenHandsConfig,
event_stream: EventStream,
llm_registry: LLMRegistry,
sid: str = 'default',
plugins: list[PluginRequirement] | None = None,
env_vars: dict[str, str] | None = None,
status_callback: Any | None = None,
attach_to_existing: bool = False,
headless_mode: bool = True,
user_id: str | None = None,
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
):
self.session = HttpSession()
self.action_semaphore = threading.Semaphore(1) # Ensure one action at a time
self._runtime_closed: bool = False
self._vscode_token: str | None = None # initial dummy value
self._last_updated_mcp_stdio_servers: dict[str, StdioMCPServer] = {}
super().__init__(
config,
event_stream,
llm_registry,
sid,
plugins,
env_vars,
status_callback,
attach_to_existing,
headless_mode,
user_id,
git_provider_tokens,
)
@property
def action_execution_server_url(self) -> str:
raise NotImplementedError('Action execution server URL is not implemented')
@retry(
retry=retry_if_exception(_is_retryable_error),
stop=stop_after_attempt(5) | stop_if_should_exit(),
wait=wait_exponential(multiplier=1, min=4, max=15),
)
def _send_action_server_request(
self,
method: str,
url: str,
**kwargs,
) -> httpx.Response:
"""Send a request to the action execution server.
Args:
method: HTTP method (GET, POST, etc.)
url: URL to send the request to
**kwargs: Additional arguments to pass to requests.request()
Returns:
Response from the server
Raises:
AgentRuntimeError: If the request fails
"""
return send_request(self.session, method, url, **kwargs)
def check_if_alive(self) -> None:
request_url = f'{self.action_execution_server_url}/alive'
self.log('debug', f'Sending request to: {request_url}')
response = self._send_action_server_request(
'GET',
request_url,
timeout=5,
)
self.log('debug', f'Response status code: {response.status_code}')
self.log('debug', f'Response text: {response.text}')
assert response.is_closed
def list_files(self, path: str | None = None) -> list[str]:
"""List files in the sandbox.
If path is None, list files in the sandbox's initial working directory (e.g., /workspace).
"""
try:
data = {}
if path is not None:
data['path'] = path
response = self._send_action_server_request(
'POST',
f'{self.action_execution_server_url}/list_files',
json=data,
timeout=10,
)
assert response.is_closed
response_json = response.json()
assert isinstance(response_json, list)
return response_json
except httpx.TimeoutException:
raise TimeoutError('List files operation timed out')
def copy_from(self, path: str) -> Path:
"""Zip all files in the sandbox and return as a stream of bytes."""
try:
params = {'path': path}
with self.session.stream(
'GET',
f'{self.action_execution_server_url}/download_files',
params=params,
timeout=30,
) as response:
with tempfile.NamedTemporaryFile(
suffix='.zip', delete=False
) as temp_file:
for chunk in response.iter_bytes():
temp_file.write(chunk)
temp_file.flush()
return Path(temp_file.name)
except httpx.TimeoutException:
raise TimeoutError('Copy operation timed out')
def copy_to(
self, host_src: str, sandbox_dest: str, recursive: bool = False
) -> None:
if not os.path.exists(host_src):
raise FileNotFoundError(f'Source file {host_src} does not exist')
temp_zip_path: str | None = None # Define temp_zip_path outside the try block
try:
params = {'destination': sandbox_dest, 'recursive': str(recursive).lower()}
file_to_upload = None
upload_data = {}
if recursive:
# Create and write the zip file inside the try block
with tempfile.NamedTemporaryFile(
suffix='.zip', delete=False
) as temp_zip:
temp_zip_path = temp_zip.name
try:
with ZipFile(temp_zip_path, 'w') as zipf:
for root, _, files in os.walk(host_src):
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(
file_path, os.path.dirname(host_src)
)
zipf.write(file_path, arcname)
self.log(
'debug',
f'Opening temporary zip file for upload: {temp_zip_path}',
)
file_to_upload = open(temp_zip_path, 'rb')
upload_data = {'file': file_to_upload}
except Exception as e:
# Ensure temp file is cleaned up if zipping fails
if temp_zip_path and os.path.exists(temp_zip_path):
os.unlink(temp_zip_path)
raise e # Re-raise the exception after cleanup attempt
else:
file_to_upload = open(host_src, 'rb')
upload_data = {'file': file_to_upload}
params = {'destination': sandbox_dest, 'recursive': str(recursive).lower()}
response = self._send_action_server_request(
'POST',
f'{self.action_execution_server_url}/upload_file',
files=upload_data,
params=params,
timeout=300,
)
self.log(
'debug',
f'Copy completed: host:{host_src} -> runtime:{sandbox_dest}. Response: {response.text}',
)
finally:
if file_to_upload:
file_to_upload.close()
# Cleanup the temporary zip file if it was created
if temp_zip_path and os.path.exists(temp_zip_path):
try:
os.unlink(temp_zip_path)
except Exception as e:
self.log(
'error',
f'Failed to delete temporary zip file {temp_zip_path}: {e}',
)
def get_vscode_token(self) -> str:
if self.vscode_enabled and self.runtime_initialized:
if self._vscode_token is not None: # cached value
return self._vscode_token
response = self._send_action_server_request(
'GET',
f'{self.action_execution_server_url}/vscode/connection_token',
timeout=10,
)
response_json = response.json()
assert isinstance(response_json, dict)
if response_json['token'] is None:
return ''
self._vscode_token = response_json['token']
return response_json['token']
else:
return ''
def send_action_for_execution(self, action: Action) -> Observation:
if (
isinstance(action, FileEditAction)
and action.impl_source == FileEditSource.LLM_BASED_EDIT
):
return self.llm_based_edit(action)
# set timeout to default if not set
if action.timeout is None:
if isinstance(action, CmdRunAction) and action.blocking:
raise RuntimeError('Blocking command with no timeout set')
# We don't block the command if this is a default timeout action
action.set_hard_timeout(self.config.sandbox.timeout, blocking=False)
with self.action_semaphore:
if not action.runnable:
if isinstance(action, AgentThinkAction):
return AgentThinkObservation('Your thought has been logged.')
return NullObservation('')
if (
hasattr(action, 'confirmation_state')
and action.confirmation_state
== ActionConfirmationStatus.AWAITING_CONFIRMATION
):
return NullObservation('')
action_type = action.action # type: ignore[attr-defined]
if action_type not in ACTION_TYPE_TO_CLASS:
raise ValueError(f'Action {action_type} does not exist.')
if not hasattr(self, action_type):
return ErrorObservation(
f'Action {action_type} is not supported in the current runtime.',
error_id='AGENT_ERROR$BAD_ACTION',
)
if (
getattr(action, 'confirmation_state', None)
== ActionConfirmationStatus.REJECTED
):
return UserRejectObservation(
'Action has been rejected by the user! Waiting for further user input.'
)
assert action.timeout is not None
try:
execution_action_body: dict[str, Any] = {
'action': event_to_dict(action),
}
response = self._send_action_server_request(
'POST',
f'{self.action_execution_server_url}/execute_action',
json=execution_action_body,
# wait a few more seconds to get the timeout error from client side
timeout=action.timeout + 5,
)
assert response.is_closed
output = response.json()
if getattr(action, 'hidden', False):
output.get('extras')['hidden'] = True
obs = observation_from_dict(output)
obs._cause = action.id # type: ignore[attr-defined]
except httpx.TimeoutException:
raise AgentRuntimeTimeoutError(
f'Runtime failed to return execute_action before the requested timeout of {action.timeout}s'
)
finally:
update_last_execution_time()
return obs
def run(self, action: CmdRunAction) -> Observation:
return self.send_action_for_execution(action)
def run_ipython(self, action: IPythonRunCellAction) -> Observation:
return self.send_action_for_execution(action)
def read(self, action: FileReadAction) -> Observation:
return self.send_action_for_execution(action)
def write(self, action: FileWriteAction) -> Observation:
return self.send_action_for_execution(action)
def edit(self, action: FileEditAction) -> Observation:
return self.send_action_for_execution(action)
def browse(self, action: BrowseURLAction) -> Observation:
return self.send_action_for_execution(action)
def browse_interactive(self, action: BrowseInteractiveAction) -> Observation:
return self.send_action_for_execution(action)
def get_mcp_config(
self, extra_stdio_servers: dict[str, StdioMCPServer] | None = None
) -> MCPConfig:
import sys
if sys.platform == 'win32':
self.log('debug', 'MCP is disabled on Windows, returning empty config')
return MCPConfig(mcpServers={})
updated_mcp_config = self.config.mcp.model_copy()
# Collect current stdio servers from config + extras
current_stdio: dict[str, StdioMCPServer] = {
name: server
for name, server in updated_mcp_config.mcpServers.items()
if isinstance(server, StdioMCPServer)
}
if extra_stdio_servers:
current_stdio.update(extra_stdio_servers)
# Find servers not yet sent to the action execution server
new_servers = {
name: server
for name, server in current_stdio.items()
if name not in self._last_updated_mcp_stdio_servers
}
self.log(
'debug',
f'adding {len(new_servers)} new stdio servers to MCP config: {list(new_servers.keys())}',
)
if new_servers:
# Merge current + previously-sent for the update payload
combined = {**self._last_updated_mcp_stdio_servers, **current_stdio}
stdio_tools = [
{'name': name, **server.model_dump(mode='json')}
for name, server in sorted(combined.items())
]
self.log(
'debug',
f'Updating MCP server with {len(new_servers)} new stdio servers (total: {len(combined)})',
)
response = self._send_action_server_request(
'POST',
f'{self.action_execution_server_url}/update_mcp_server',
json=stdio_tools,
timeout=60,
)
result = response.json()
if response.status_code != 200:
self.log('warning', f'Failed to update MCP server: {response.text}')
else:
if result.get('router_error_log'):
self.log(
'warning',
f'Some MCP servers failed to be added: {result["router_error_log"]}',
)
self._last_updated_mcp_stdio_servers = dict(combined)
self.log(
'debug',
f'Successfully updated MCP stdio servers, now tracking {len(combined)} servers',
)
self.log(
'info',
f'Updated MCP config: {list(updated_mcp_config.mcpServers.keys())}',
)
else:
self.log('debug', 'No new stdio servers to update')
# Expose the runtime's MCP SSE proxy when stdio servers exist
if self._last_updated_mcp_stdio_servers:
updated_mcp_config.mcpServers['_runtime_proxy'] = RemoteMCPServer(
url=self.action_execution_server_url.rstrip('/') + '/mcp/sse',
transport='sse',
auth=self.session_api_key,
)
return updated_mcp_config
async def call_tool_mcp(self, action: MCPAction) -> Observation:
import sys
from openhands.events.observation import ErrorObservation
# Check if we're on Windows - MCP is disabled on Windows
if sys.platform == 'win32':
self.log('info', 'MCP functionality is disabled on Windows')
return ErrorObservation('MCP functionality is not available on Windows')
# Import here to avoid circular imports
from openhands.mcp.utils import call_tool_mcp as call_tool_mcp_handler
from openhands.mcp.utils import create_mcp_clients
# Get the updated MCP config
updated_mcp_config = self.get_mcp_config()
self.log(
'debug',
f'Creating MCP clients with servers: {list(updated_mcp_config.mcpServers.keys())}',
)
# Create clients for this specific operation
mcp_clients = await create_mcp_clients(updated_mcp_config, self.sid)
# Call the tool and return the result
# No need for try/finally since disconnect() is now just resetting state
result = await call_tool_mcp_handler(mcp_clients, action)
return result
def close(self) -> None:
# Make sure we don't close the session multiple times
# Can happen in evaluation
if self._runtime_closed:
return
self._runtime_closed = True
self.session.close()
+12
View File
@@ -0,0 +1,12 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
"""CLI Runtime implementation for OpenHands."""
from openhands.runtime.impl.cli.cli_runtime import CLIRuntime
__all__ = ['CLIRuntime']
+959
View File
@@ -0,0 +1,959 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
"""This runtime runs commands locally using subprocess and performs file operations using Python's standard library.
It does not implement browser functionality.
"""
import asyncio
import os
import select
import shutil
import signal
import subprocess
import sys
import tempfile
import time
import zipfile
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable
from binaryornot.check import is_binary
from openhands_aci.editor.editor import OHEditor
from openhands_aci.editor.exceptions import ToolError
from openhands_aci.editor.results import ToolResult
from openhands_aci.utils.diff import get_diff
from pydantic import SecretStr
from openhands.core.config import OpenHandsConfig
from openhands.core.config.mcp_config import MCPConfig, StdioMCPServer
from openhands.core.logger import openhands_logger as logger
from openhands.events import EventStream
from openhands.events.action import (
BrowseInteractiveAction,
BrowseURLAction,
CmdRunAction,
FileEditAction,
FileReadAction,
FileWriteAction,
IPythonRunCellAction,
)
from openhands.events.action.mcp import MCPAction
from openhands.events.event import FileEditSource, FileReadSource
from openhands.events.observation import (
CmdOutputObservation,
ErrorObservation,
FileEditObservation,
FileReadObservation,
FileWriteObservation,
Observation,
)
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.llm.llm_registry import LLMRegistry
from openhands.runtime.base import Runtime
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.runtime_status import RuntimeStatus
if TYPE_CHECKING:
from openhands.runtime.utils.windows_bash import WindowsPowershellSession
# Import Windows PowerShell support if on Windows
if sys.platform == 'win32':
try:
from openhands.runtime.utils.windows_exceptions import DotNetMissingError
from openhands.runtime.utils.windows_bash import WindowsPowershellSession # isort: skip
except (ImportError, DotNetMissingError) as err:
# Print a user-friendly error message without stack trace
friendly_message = """
ERROR: PowerShell and .NET SDK are required but not properly configured
The .NET SDK and PowerShell are required for OpenHands CLI on Windows.
PowerShell integration cannot function without .NET Core.
Please install the .NET SDK by following the instructions at:
https://docs.all-hands.dev/usage/windows-without-wsl
After installing .NET SDK, restart your terminal and try again.
"""
print(friendly_message, file=sys.stderr)
logger.error(
f'Windows runtime initialization failed: {type(err).__name__}: {str(err)}'
)
if (
isinstance(err, DotNetMissingError)
and hasattr(err, 'details')
and err.details
):
logger.debug(f'Details: {err.details}')
# Exit the program with an error code
sys.exit(1)
class CLIRuntime(Runtime):
"""A runtime implementation that runs commands locally using subprocess and performs
file operations using Python's standard library. It does not implement browser functionality.
Args:
config (OpenHandsConfig): The application configuration.
event_stream (EventStream): The event stream to subscribe to.
sid (str, optional): The session ID. Defaults to 'default'.
plugins (list[PluginRequirement] | None, optional): List of plugin requirements. Defaults to None.
env_vars (dict[str, str] | None, optional): Environment variables to set. Defaults to None.
status_callback (Callable | None, optional): Callback for status updates. Defaults to None.
attach_to_existing (bool, optional): Whether to attach to an existing session. Defaults to False.
headless_mode (bool, optional): Whether to run in headless mode. Defaults to False.
user_id (str | None, optional): User ID for authentication. Defaults to None.
git_provider_tokens (PROVIDER_TOKEN_TYPE | None, optional): Git provider tokens. Defaults to None.
"""
def __init__(
self,
config: OpenHandsConfig,
event_stream: EventStream,
llm_registry: LLMRegistry,
sid: str = 'default',
plugins: list[PluginRequirement] | None = None,
env_vars: dict[str, str] | None = None,
status_callback: Callable[[str, RuntimeStatus, str], None] | None = None,
attach_to_existing: bool = False,
headless_mode: bool = False,
user_id: str | None = None,
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
):
super().__init__(
config,
event_stream,
llm_registry,
sid,
plugins,
env_vars,
status_callback,
attach_to_existing,
headless_mode,
user_id,
git_provider_tokens,
)
# Set up workspace
if self.config.workspace_base is not None:
logger.warning(
f'Workspace base path is set to {self.config.workspace_base}. '
'It will be used as the path for the agent to run in. '
'Be careful, the agent can EDIT files in this directory!'
)
self._workspace_path = self.config.workspace_base
else:
# Create a temporary directory for the workspace
self._workspace_path = tempfile.mkdtemp(
prefix=f'openhands_workspace_{sid}_'
)
logger.info(f'Created temporary workspace at {self._workspace_path}')
# Runtime tests rely on this being set correctly.
self.config.workspace_mount_path_in_sandbox = self._workspace_path
# Initialize runtime state
self._runtime_initialized = False
self.file_editor = OHEditor(workspace_root=self._workspace_path)
self._shell_stream_callback: Callable[[str], None] | None = None
# Initialize PowerShell session on Windows
self._is_windows = sys.platform == 'win32'
self._powershell_session: WindowsPowershellSession | None = None
logger.warning(
'Initializing CLIRuntime. WARNING: NO SANDBOX IS USED. '
'This runtime executes commands directly on the local system. '
'Use with caution in untrusted environments.'
)
async def connect(self) -> None:
"""Initialize the runtime connection."""
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
# Ensure workspace directory exists
os.makedirs(self._workspace_path, exist_ok=True)
# Change to the workspace directory
os.chdir(self._workspace_path)
# Initialize PowerShell session if on Windows
if self._is_windows:
self._powershell_session = WindowsPowershellSession(
work_dir=self._workspace_path,
username=None, # Use current user
no_change_timeout_seconds=30,
max_memory_mb=None,
)
if not self.attach_to_existing:
await asyncio.to_thread(self.setup_initial_env)
self._runtime_initialized = True
self.set_runtime_status(RuntimeStatus.RUNTIME_STARTED)
logger.info(f'CLIRuntime initialized with workspace at {self._workspace_path}')
def add_env_vars(self, env_vars: dict[str, Any]) -> None:
"""Adds environment variables to the current runtime environment.
For CLIRuntime, this means updating os.environ for the current process,
so that subsequent commands inherit these variables.
This overrides the BaseRuntime behavior which tries to run shell commands
before it's initialized and modify .bashrc, which is not ideal for local CLI.
"""
if not env_vars:
return
# We log only keys to avoid leaking sensitive values like tokens into logs.
logger.info(
f'[CLIRuntime] Setting environment variables for this session: {list(env_vars.keys())}'
)
for key, value in env_vars.items():
if isinstance(value, SecretStr):
os.environ[key] = value.get_secret_value()
logger.warning(f'[CLIRuntime] Set os.environ["{key}"] (from SecretStr)')
else:
os.environ[key] = value
logger.debug(f'[CLIRuntime] Set os.environ["{key}"]')
# We don't use self.run() here because this method is called
# during initialization before self._runtime_initialized is True.
def _safe_terminate_process(self, process_obj, signal_to_send=signal.SIGTERM):
"""Safely attempts to terminate/kill a process group or a single process.
Args:
process_obj: the subprocess.Popen object started with start_new_session=True
signal_to_send: the signal to send to the process group or process.
"""
pid = getattr(process_obj, 'pid', None)
if pid is None:
return
group_desc = (
'kill process group'
if signal_to_send == signal.SIGKILL
else 'terminate process group'
)
process_desc = (
'kill process' if signal_to_send == signal.SIGKILL else 'terminate process'
)
try:
# Try to terminate/kill the entire process group
logger.debug(f'[_safe_terminate_process] Original PID to act on: {pid}')
pgid_to_kill = os.getpgid(
pid
) # This might raise ProcessLookupError if pid is already gone
logger.debug(
f'[_safe_terminate_process] Attempting to {group_desc} for PID {pid} (PGID: {pgid_to_kill}) with {signal_to_send}.'
)
os.killpg(pgid_to_kill, signal_to_send)
logger.debug(
f'[_safe_terminate_process] Successfully sent signal {signal_to_send} to PGID {pgid_to_kill} (original PID: {pid}).'
)
except ProcessLookupError as e_pgid:
logger.warning(
f'[_safe_terminate_process] ProcessLookupError getting PGID for PID {pid} (it might have already exited): {e_pgid}. Falling back to direct kill/terminate.'
)
try:
if signal_to_send == signal.SIGKILL:
process_obj.kill()
else:
process_obj.terminate()
logger.debug(
f'[_safe_terminate_process] Fallback: Terminated {process_desc} (PID: {pid}).'
)
except Exception as e_fallback:
logger.error(
f'[_safe_terminate_process] Fallback: Error during {process_desc} (PID: {pid}): {e_fallback}'
)
except (AttributeError, OSError) as e_os:
logger.error(
f'[_safe_terminate_process] OSError/AttributeError during {group_desc} for PID {pid}: {e_os}. Falling back.'
)
# Fallback: try to terminate/kill the main process directly.
try:
if signal_to_send == signal.SIGKILL:
process_obj.kill()
else:
process_obj.terminate()
logger.debug(
f'[_safe_terminate_process] Fallback: Terminated {process_desc} (PID: {pid}).'
)
except Exception as e_fallback:
logger.error(
f'[_safe_terminate_process] Fallback: Error during {process_desc} (PID: {pid}): {e_fallback}'
)
except (KeyboardInterrupt, SystemExit):
raise
except Exception as e:
logger.error(f'Error: {e}')
def _execute_powershell_command(
self, command: str, timeout: float
) -> CmdOutputObservation | ErrorObservation:
"""Execute a command using PowerShell session on Windows.
Args:
command: The command to execute
timeout: Timeout in seconds for the command
Returns:
CmdOutputObservation containing the complete output and exit code
"""
if self._powershell_session is None:
return ErrorObservation(
content='PowerShell session is not available.',
error_id='POWERSHELL_SESSION_ERROR',
)
try:
# Create a CmdRunAction for the PowerShell session
from openhands.events.action import CmdRunAction
ps_action = CmdRunAction(command=command)
ps_action.set_hard_timeout(timeout)
# Execute the command using the PowerShell session
return self._powershell_session.execute(ps_action)
except Exception as e:
logger.error(f'Error executing PowerShell command "{command}": {e}')
return ErrorObservation(
content=f'Error executing PowerShell command "{command}": {str(e)}',
error_id='POWERSHELL_EXECUTION_ERROR',
)
def _execute_shell_command(
self, command: str, timeout: float
) -> CmdOutputObservation:
"""Execute a shell command and stream its output to a callback function.
Args:
command: The shell command to execute
timeout: Timeout in seconds for the command
Returns:
CmdOutputObservation containing the complete output and exit code
"""
output_lines = []
timed_out = False
start_time = time.monotonic()
# Use shell=True to run complex bash commands
process = subprocess.Popen(
['bash', '-c', command],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1, # Explicitly line-buffered for text mode
universal_newlines=True,
start_new_session=True,
)
logger.debug(
f'[_execute_shell_command] PID of bash -c: {process.pid} for command: "{command}"'
)
exit_code = None
try:
if process.stdout:
while process.poll() is None:
if (
timeout is not None
and (time.monotonic() - start_time) > timeout
):
logger.debug(
f'Command "{command}" timed out after {timeout:.1f} seconds. Terminating.'
)
# Attempt to terminate the process group (SIGTERM)
self._safe_terminate_process(
process, signal_to_send=signal.SIGTERM
)
timed_out = True
break
ready_to_read, _, _ = select.select([process.stdout], [], [], 0.1)
if ready_to_read:
line = process.stdout.readline()
if line:
output_lines.append(line)
if self._shell_stream_callback:
self._shell_stream_callback(line)
# Attempt to read any remaining data from stdout
if process.stdout and not process.stdout.closed:
try:
while line:
line = process.stdout.readline()
if line:
output_lines.append(line)
if self._shell_stream_callback:
self._shell_stream_callback(line)
except Exception as e:
logger.warning(
f'Error reading directly from stdout after loop for "{command}": {e}'
)
exit_code = process.returncode
# If timeout occurred, ensure exit_code reflects this for the observation.
if timed_out:
exit_code = -1
except Exception as e:
logger.error(
f'Outer exception in _execute_shell_command for "{command}": {e}'
)
if process and process.poll() is None:
self._safe_terminate_process(process, signal_to_send=signal.SIGKILL)
return CmdOutputObservation(
command=command,
content=''.join(output_lines) + f'\nError during execution: {e}',
exit_code=-1,
)
complete_output = ''.join(output_lines)
logger.debug(
f'[_execute_shell_command] Complete output for "{command}" (len: {len(complete_output)}): {complete_output!r}'
)
obs_metadata = {'working_dir': self._workspace_path}
if timed_out:
obs_metadata['suffix'] = (
f'[The command timed out after {timeout:.1f} seconds.]'
)
# exit_code = -1 # This is already set if timed_out is True
return CmdOutputObservation(
command=command,
content=complete_output,
exit_code=exit_code,
metadata=obs_metadata,
)
def run(self, action: CmdRunAction) -> Observation:
"""Run a command using subprocess."""
if not self._runtime_initialized:
return ErrorObservation(
f'Runtime not initialized for command: {action.command}'
)
if action.is_input:
logger.warning(
f"CLIRuntime received an action with `is_input=True` (command: '{action.command}'). "
'CLIRuntime currently does not support sending input or signals to active processes. '
'This action will be ignored and an error observation will be returned.'
)
return ErrorObservation(
content=f"CLIRuntime does not support interactive input from the agent (e.g., 'C-c'). The command '{action.command}' was not sent to any process.",
error_id='AGENT_ERROR$BAD_ACTION',
)
try:
effective_timeout = (
action.timeout
if action.timeout is not None
else self.config.sandbox.timeout
)
logger.debug(
f'Running command in CLIRuntime: "{action.command}" with effective timeout: {effective_timeout}s'
)
# Use PowerShell on Windows if available, otherwise use subprocess
if self._is_windows and self._powershell_session is not None:
return self._execute_powershell_command(
action.command, timeout=effective_timeout
)
else:
return self._execute_shell_command(
action.command, timeout=effective_timeout
)
except Exception as e:
logger.error(
f'Error in CLIRuntime.run for command "{action.command}": {str(e)}'
)
return ErrorObservation(
f'Error running command "{action.command}": {str(e)}'
)
def run_ipython(self, action: IPythonRunCellAction) -> Observation:
"""Run a Python code cell.
This functionality is not implemented in CLIRuntime.
Users should also disable the Jupyter plugin in AgentConfig.
"""
# This functionality is not implemented in CLIRuntime.
# If you need to run IPython/Jupyter cells, please consider using a different runtime
# or ensure the Jupyter plugin is disabled in your AgentConfig to avoid
# attempting to use this disabled feature.
logger.warning(
"run_ipython is called on CLIRuntime, but it's not implemented. "
'Please disable the Jupyter plugin in AgentConfig.'
)
return ErrorObservation(
'Executing IPython cells is not implemented in CLIRuntime. '
)
def _sanitize_filename(self, filename: str) -> str:
# if path is absolute, ensure it starts with _workspace_path
if filename == '/workspace':
actual_filename = self._workspace_path
elif filename.startswith('/workspace/'):
# Map /workspace/ to the actual workspace path
# Note: /workspace is widely used, so we map it to allow using it with CLIRuntime
actual_filename = os.path.join(
self._workspace_path, filename[len('/workspace/') :]
)
elif filename.startswith('/'):
if not filename.startswith(self._workspace_path):
raise PermissionError(
f'Invalid path: {filename}. You can only work with files in {self._workspace_path}.'
)
actual_filename = filename
else:
actual_filename = os.path.join(self._workspace_path, filename.lstrip('/'))
# Resolve the path to handle any '..' or '.' components
resolved_path = os.path.realpath(actual_filename)
# Check if the resolved path is still within the workspace
if not resolved_path.startswith(self._workspace_path):
raise PermissionError(
f'Invalid path traversal: {filename}. Path resolves outside the workspace. Resolved: {resolved_path}, Workspace: {self._workspace_path}'
)
return resolved_path
def read(self, action: FileReadAction) -> Observation:
"""Read a file using Python's standard library or OHEditor."""
if not self._runtime_initialized:
return ErrorObservation('Runtime not initialized')
file_path = self._sanitize_filename(action.path)
# Use OHEditor for OH_ACI implementation source
if action.impl_source == FileReadSource.OH_ACI:
result_str, _ = self._execute_file_editor(
command='view',
path=file_path,
view_range=action.view_range,
)
return FileReadObservation(
content=result_str,
path=action.path,
impl_source=FileReadSource.OH_ACI,
)
try:
# Check if the file exists
if not os.path.exists(file_path):
return ErrorObservation(f'File not found: {action.path}')
# Check if it's a directory
if os.path.isdir(file_path):
return ErrorObservation(f'Cannot read directory: {action.path}')
# Cannot read binary files
if is_binary(file_path):
return ErrorObservation('ERROR_BINARY_FILE')
# Read the file
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
content = f.read()
return FileReadObservation(content=content, path=action.path)
except Exception as e:
logger.error(f'Error reading file: {str(e)}')
return ErrorObservation(f'Error reading file {action.path}: {str(e)}')
def write(self, action: FileWriteAction) -> Observation:
"""Write to a file using Python's standard library."""
if not self._runtime_initialized:
return ErrorObservation('Runtime not initialized')
file_path = self._sanitize_filename(action.path)
try:
# Create parent directories if they don't exist
os.makedirs(os.path.dirname(file_path), exist_ok=True)
# Write to the file
with open(file_path, 'w', encoding='utf-8') as f:
f.write(action.content)
return FileWriteObservation(content='', path=action.path)
except Exception as e:
logger.error(f'Error writing to file: {str(e)}')
return ErrorObservation(f'Error writing to file {action.path}: {str(e)}')
def browse(self, action: BrowseURLAction) -> Observation:
"""Not implemented for CLI runtime."""
return ErrorObservation(
'Browser functionality is not implemented in CLIRuntime'
)
def browse_interactive(self, action: BrowseInteractiveAction) -> Observation:
"""Not implemented for CLI runtime."""
return ErrorObservation(
'Browser functionality is not implemented in CLIRuntime'
)
def _execute_file_editor(
self,
command: str,
path: str,
file_text: str | None = None,
view_range: list[int] | None = None,
old_str: str | None = None,
new_str: str | None = None,
insert_line: int | None = None,
enable_linting: bool = False,
) -> tuple[str, tuple[str | None, str | None]]:
"""Execute file editor command and handle exceptions.
Args:
command: Editor command to execute
path: File path
file_text: Optional file text content
view_range: Optional view range tuple (start, end)
old_str: Optional string to replace
new_str: Optional replacement string
insert_line: Optional line number for insertion
enable_linting: Whether to enable linting
Returns:
tuple: A tuple containing the output string and a tuple of old and new file content
"""
result: ToolResult | None = None
try:
result = self.file_editor(
command=command,
path=path,
file_text=file_text,
view_range=view_range,
old_str=old_str,
new_str=new_str,
insert_line=insert_line,
enable_linting=enable_linting,
)
except ToolError as e:
result = ToolResult(error=e.message)
if result.error:
return f'ERROR:\n{result.error}', (None, None)
if not result.output:
logger.warning(f'No output from file_editor for {path}')
return '', (None, None)
return result.output, (result.old_content, result.new_content)
def edit(self, action: FileEditAction) -> Observation:
"""Edit a file using the OHEditor."""
if not self._runtime_initialized:
return ErrorObservation('Runtime not initialized')
# Ensure the path is within the workspace
file_path = self._sanitize_filename(action.path)
# Check if it's a binary file
if os.path.exists(file_path) and is_binary(file_path):
return ErrorObservation('ERROR_BINARY_FILE')
assert action.impl_source == FileEditSource.OH_ACI
result_str, (old_content, new_content) = self._execute_file_editor(
command=action.command,
path=file_path,
file_text=action.file_text,
old_str=action.old_str,
new_str=action.new_str,
insert_line=action.insert_line,
enable_linting=False,
)
return FileEditObservation(
content=result_str,
path=action.path,
old_content=action.old_str,
new_content=action.new_str,
impl_source=FileEditSource.OH_ACI,
diff=get_diff(
old_contents=old_content or '',
new_contents=new_content or '',
filepath=action.path,
),
)
async def call_tool_mcp(self, action: MCPAction) -> Observation:
"""Execute an MCP tool action in CLI runtime.
Args:
action: The MCP action to execute
Returns:
Observation: The result of the MCP tool execution
"""
# Check if we're on Windows - MCP is disabled on Windows
if sys.platform == 'win32':
self.log('info', 'MCP functionality is disabled on Windows')
return ErrorObservation('MCP functionality is not available on Windows')
# Import here to avoid circular imports
from openhands.mcp.utils import call_tool_mcp as call_tool_mcp_handler
from openhands.mcp.utils import create_mcp_clients
try:
# Get the MCP config for this runtime
mcp_config = self.get_mcp_config()
if not mcp_config.mcpServers:
self.log('warning', 'No MCP servers configured')
return ErrorObservation('No MCP servers configured')
self.log(
'debug',
f'Creating MCP clients for action {action.name} with '
f'{len(mcp_config.mcpServers)} servers',
)
# Create clients for this specific operation
mcp_clients = await create_mcp_clients(mcp_config, self.sid)
if not mcp_clients:
self.log('warning', 'No MCP clients could be created')
return ErrorObservation(
'No MCP clients could be created - check server configurations'
)
# Call the tool and return the result
self.log(
'debug',
f'Executing MCP tool: {action.name} with arguments: {action.arguments}',
)
result = await call_tool_mcp_handler(mcp_clients, action)
self.log('debug', f'MCP tool {action.name} executed successfully')
return result
except Exception as e:
error_msg = f'Error executing MCP tool {action.name}: {str(e)}'
self.log('error', error_msg)
return ErrorObservation(error_msg)
@property
def workspace_root(self) -> Path:
"""Return the workspace root path."""
return Path(os.path.abspath(self._workspace_path))
def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False):
"""Copy a file or directory from the host to the sandbox."""
if not self._runtime_initialized:
raise RuntimeError('Runtime not initialized')
if not os.path.exists(host_src): # Source must exist on host
raise FileNotFoundError(f"Source path '{host_src}' does not exist.")
dest = self._sanitize_filename(sandbox_dest)
try:
# Case 1: Source is a directory and recursive copy.
if os.path.isdir(host_src) and recursive:
# Target is dest / basename(host_src)
final_target_dir = os.path.join(dest, os.path.basename(host_src))
# If source and final target are the same, skip.
if os.path.realpath(host_src) == os.path.realpath(final_target_dir):
logger.debug(
'Skipping recursive copy: source and target are identical.'
)
pass
else:
# Ensure parent of final_target_dir exists.
os.makedirs(dest, exist_ok=True)
shutil.copytree(host_src, final_target_dir, dirs_exist_ok=True)
# Why: Copies dir host_src into dest. Merges if target exists.
# Case 2: Source is a file.
elif os.path.isfile(host_src):
final_target_file_path: str
# Scenario A: sandbox_dest is clearly a directory.
if os.path.isdir(dest) or (sandbox_dest.endswith(('/', os.sep))):
target_dir = dest
os.makedirs(target_dir, exist_ok=True)
final_target_file_path = os.path.join(
target_dir, os.path.basename(host_src)
)
# Why: Copies file into specified directory.
# Scenario B: sandbox_dest is likely a new directory (e.g., 'new_dir').
elif not os.path.exists(dest) and '.' not in os.path.basename(dest):
target_dir = dest
os.makedirs(target_dir, exist_ok=True)
final_target_file_path = os.path.join(
target_dir, os.path.basename(host_src)
)
# Why: Creates 'new_dir' and copies file into it.
# Scenario C: sandbox_dest is a full file path.
else:
final_target_file_path = dest
os.makedirs(os.path.dirname(final_target_file_path), exist_ok=True)
# Why: Copies file to a specific path, possibly renaming.
shutil.copy2(host_src, final_target_file_path)
else: # Source is not a valid file or directory.
raise FileNotFoundError(
f"Source path '{host_src}' is not a valid file or directory."
)
except FileNotFoundError as e:
logger.error(f'File not found during copy: {str(e)}')
raise
except shutil.SameFileError as e:
# We can be lenient here, just ignore this error.
logger.debug(
f'Skipping copy as source and destination are the same: {str(e)}'
)
pass
except Exception as e:
logger.error(f'Unexpected error copying file: {str(e)}')
raise RuntimeError(f'Unexpected error copying file: {str(e)}')
def list_files(self, path: str | None = None) -> list[str]:
"""List files in the sandbox."""
if not self._runtime_initialized:
raise RuntimeError('Runtime not initialized')
if path is None:
dir_path = self._workspace_path
else:
dir_path = self._sanitize_filename(path)
try:
if not os.path.exists(dir_path):
return []
if not os.path.isdir(dir_path):
return [dir_path]
# List files in the directory
return [os.path.join(dir_path, f) for f in os.listdir(dir_path)]
except Exception as e:
logger.error(f'Error listing files: {str(e)}')
return []
def copy_from(self, path: str) -> Path:
"""Zip all files in the sandbox and return a path in the local filesystem."""
if not self._runtime_initialized:
raise RuntimeError('Runtime not initialized')
source_path = self._sanitize_filename(path)
if not os.path.exists(source_path):
raise FileNotFoundError(f'Path not found: {path}')
# Create a temporary zip file
temp_zip = tempfile.NamedTemporaryFile(suffix='.zip', delete=False)
temp_zip.close()
try:
with zipfile.ZipFile(temp_zip.name, 'w', zipfile.ZIP_DEFLATED) as zipf:
if os.path.isdir(source_path):
# Add all files in the directory
for root, _, files in os.walk(source_path):
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, source_path)
zipf.write(file_path, arcname)
else:
# Add a single file
zipf.write(source_path, os.path.basename(source_path))
return Path(temp_zip.name)
except Exception as e:
logger.error(f'Error creating zip file: {str(e)}')
raise RuntimeError(f'Error creating zip file: {str(e)}')
def close(self) -> None:
# Clean up PowerShell session if it exists
if self._powershell_session is not None:
try:
self._powershell_session.close()
logger.debug('PowerShell session closed successfully.')
except Exception as e:
logger.warning(f'Error closing PowerShell session: {e}')
finally:
self._powershell_session = None
self._runtime_initialized = False
super().close()
@classmethod
async def delete(cls, conversation_id: str) -> None:
"""Delete any resources associated with a conversation."""
# Look for temporary directories that might be associated with this conversation
temp_dir = tempfile.gettempdir()
prefix = f'openhands_workspace_{conversation_id}_'
for item in os.listdir(temp_dir):
if item.startswith(prefix):
try:
path = os.path.join(temp_dir, item)
if os.path.isdir(path):
shutil.rmtree(path)
logger.info(f'Deleted workspace directory: {path}')
except Exception as e:
logger.error(f'Error deleting workspace directory: {str(e)}')
@property
def additional_agent_instructions(self) -> str:
return '\n\n'.join(
[
f'Your working directory is {self._workspace_path}. You can only read and write files in this directory.',
"You are working directly on the user's machine. In most cases, the working environment is already set up.",
]
)
def get_mcp_config(
self, extra_stdio_servers: dict[str, StdioMCPServer] | None = None
) -> MCPConfig:
"""Get MCP configuration for CLI runtime."""
if sys.platform == 'win32':
self.log('debug', 'MCP is disabled on Windows, returning empty config')
return MCPConfig(mcpServers={})
mcp_config = self.config.mcp
if extra_stdio_servers:
merged = dict(mcp_config.mcpServers)
for name, server in extra_stdio_servers.items():
if name not in merged:
merged[name] = server
self.log('info', f'Added extra stdio server: {name}')
mcp_config = MCPConfig(mcpServers=merged)
self.config.mcp = mcp_config
self.log(
'debug',
f'CLI MCP config: {len(mcp_config.mcpServers)} servers',
)
return mcp_config
def subscribe_to_shell_stream(
self, callback: Callable[[str], None] | None = None
) -> bool:
"""Subscribe to shell command output stream.
Args:
callback: A function that will be called with each line of output from shell commands.
If None, any existing subscription will be removed.
"""
self._shell_stream_callback = callback
return True
@@ -0,0 +1,26 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
import docker
def stop_all_containers(prefix: str) -> None:
docker_client = docker.from_env()
try:
containers = docker_client.containers.list(all=True)
for container in containers:
try:
if container.name and container.name.startswith(prefix):
container.stop()
except docker.errors.APIError:
pass
except docker.errors.NotFound:
pass
except docker.errors.NotFound: # yes, this can happen!
pass
finally:
docker_client.close()
@@ -0,0 +1,778 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
import os
import platform
import typing
from functools import lru_cache
from typing import Callable
from uuid import UUID
import docker
import httpx
import tenacity
from docker.models.containers import Container
from docker.types import DriverConfig, Mount
from openhands.core.config import OpenHandsConfig
from openhands.core.exceptions import (
AgentRuntimeDisconnectedError,
AgentRuntimeNotFoundError,
)
from openhands.core.logger import DEBUG, DEBUG_RUNTIME
from openhands.core.logger import openhands_logger as logger
from openhands.events import EventStream
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.llm.llm_registry import LLMRegistry
from openhands.runtime.builder import DockerRuntimeBuilder
from openhands.runtime.impl.action_execution.action_execution_client import (
ActionExecutionClient,
)
from openhands.runtime.impl.docker.containers import stop_all_containers
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.runtime_status import RuntimeStatus
from openhands.runtime.utils import find_available_tcp_port
from openhands.runtime.utils.command import (
DEFAULT_MAIN_MODULE,
get_action_execution_server_startup_command,
)
from openhands.runtime.utils.log_streamer import LogStreamer
from openhands.runtime.utils.port_lock import PortLock, find_available_port_with_lock
from openhands.runtime.utils.runtime_build import build_runtime_image
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.shutdown_listener import add_shutdown_listener
from openhands.utils.tenacity_stop import stop_if_should_exit
CONTAINER_NAME_PREFIX = 'openhands-runtime-'
EXECUTION_SERVER_PORT_RANGE = (30000, 39999)
VSCODE_PORT_RANGE = (40000, 49999)
APP_PORT_RANGE_1 = (50000, 54999)
APP_PORT_RANGE_2 = (55000, 59999)
if os.name == 'nt' or platform.release().endswith('microsoft-standard-WSL2'):
EXECUTION_SERVER_PORT_RANGE = (30000, 34999)
VSCODE_PORT_RANGE = (35000, 39999)
APP_PORT_RANGE_1 = (40000, 44999)
APP_PORT_RANGE_2 = (45000, 49151)
def _is_retryablewait_until_alive_error(exception: BaseException) -> bool:
if isinstance(exception, tenacity.RetryError):
cause = exception.last_attempt.exception()
return cause is not None and _is_retryablewait_until_alive_error(cause)
return isinstance(
exception,
(
ConnectionError,
httpx.ConnectTimeout,
httpx.NetworkError,
httpx.RemoteProtocolError,
httpx.HTTPStatusError,
httpx.ReadTimeout,
),
)
class DockerRuntime(ActionExecutionClient):
"""This runtime will subscribe the event stream.
When receive an event, it will send the event to runtime-client which run inside the docker environment.
Args:
config (OpenHandsConfig): The application configuration.
event_stream (EventStream): The event stream to subscribe to.
sid (str, optional): The session ID. Defaults to 'default'.
plugins (list[PluginRequirement] | None, optional): List of plugin requirements. Defaults to None.
env_vars (dict[str, str] | None, optional): Environment variables to set. Defaults to None.
"""
_shutdown_listener_id: UUID | None = None
def __init__(
self,
config: OpenHandsConfig,
event_stream: EventStream,
llm_registry: LLMRegistry,
sid: str = 'default',
plugins: list[PluginRequirement] | None = None,
env_vars: dict[str, str] | None = None,
status_callback: Callable | None = None,
attach_to_existing: bool = False,
headless_mode: bool = True,
user_id: str | None = None,
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
main_module: str = DEFAULT_MAIN_MODULE,
):
if not DockerRuntime._shutdown_listener_id:
DockerRuntime._shutdown_listener_id = add_shutdown_listener(
lambda: stop_all_containers(CONTAINER_NAME_PREFIX)
)
self.config = config
self.status_callback = status_callback
self._host_port = -1
self._container_port = -1
self._vscode_port = -1
self._app_ports: list[int] = []
# Port locks to prevent race conditions
self._host_port_lock: PortLock | None = None
self._vscode_port_lock: PortLock | None = None
self._app_port_locks: list[PortLock] = []
if os.environ.get('DOCKER_HOST_ADDR'):
logger.info(
f'Using DOCKER_HOST_IP: {os.environ["DOCKER_HOST_ADDR"]} for local_runtime_url'
)
self.config.sandbox.local_runtime_url = (
f'http://{os.environ["DOCKER_HOST_ADDR"]}'
)
self.docker_client: docker.DockerClient = self._init_docker_client()
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
self.base_container_image = self.config.sandbox.base_container_image
self.runtime_container_image = self.config.sandbox.runtime_container_image
self.container_name = CONTAINER_NAME_PREFIX + sid
self.container: Container | None = None
self.main_module = main_module
self.runtime_builder = DockerRuntimeBuilder(self.docker_client)
# Buffer for container logs
self.log_streamer: LogStreamer | None = None
super().__init__(
config,
event_stream,
llm_registry,
sid,
plugins,
env_vars,
status_callback,
attach_to_existing,
headless_mode,
user_id,
git_provider_tokens,
)
# Log runtime_extra_deps after base class initialization so self.sid is available
if self.config.sandbox.runtime_extra_deps:
self.log(
'debug',
f'Installing extra user-provided dependencies in the runtime image: {self.config.sandbox.runtime_extra_deps}',
)
@property
def action_execution_server_url(self) -> str:
return self.api_url
async def connect(self) -> None:
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
try:
await call_sync_from_async(self._attach_to_container)
except docker.errors.NotFound as e:
if self.attach_to_existing:
self.log(
'warning',
f'Container {self.container_name} not found.',
)
raise AgentRuntimeDisconnectedError from e
self.maybe_build_runtime_container_image()
self.log(
'info', f'Starting runtime with image: {self.runtime_container_image}'
)
await call_sync_from_async(self.init_container)
self.log(
'info',
f'Container started: {self.container_name}. VSCode URL: {self.vscode_url}',
)
if DEBUG_RUNTIME and self.container:
self.log_streamer = LogStreamer(self.container, self.log)
else:
self.log_streamer = None
if not self.attach_to_existing:
self.log('info', f'Waiting for client to become ready at {self.api_url}...')
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
await call_sync_from_async(self.wait_until_alive)
if not self.attach_to_existing:
self.log('info', 'Runtime is ready.')
if not self.attach_to_existing:
await call_sync_from_async(self.setup_initial_env)
self.log(
'debug',
f'Container initialized with plugins: {[plugin.name for plugin in self.plugins]}. VSCode URL: {self.vscode_url}',
)
if not self.attach_to_existing:
self.set_runtime_status(RuntimeStatus.READY)
self._runtime_initialized = True
for network_name in self.config.sandbox.additional_networks:
try:
network = self.docker_client.networks.get(network_name)
if self.container is not None:
network.connect(self.container)
else:
self.log(
'warning',
f'Container not available to connect to network {network_name}',
)
except Exception as e:
self.log(
'error',
f'Error: Failed to connect instance {self.container_name} to network {network_name}',
)
self.log('error', str(e))
def maybe_build_runtime_container_image(self):
if self.runtime_container_image is None:
if self.base_container_image is None:
raise ValueError(
'Neither runtime container image nor base container image is set'
)
self.set_runtime_status(RuntimeStatus.BUILDING_RUNTIME)
self.runtime_container_image = build_runtime_image(
self.base_container_image,
self.runtime_builder,
platform=self.config.sandbox.platform,
extra_deps=self.config.sandbox.runtime_extra_deps,
force_rebuild=self.config.sandbox.force_rebuild_runtime,
extra_build_args=self.config.sandbox.runtime_extra_build_args,
enable_browser=self.config.enable_browser,
)
@staticmethod
@lru_cache(maxsize=1)
def _init_docker_client() -> docker.DockerClient:
try:
return docker.from_env()
except Exception as ex:
logger.error(
'Launch docker client failed. Please make sure you have installed docker and started docker desktop/daemon.',
)
raise ex
def _process_volumes(self) -> dict[str, dict[str, str]]:
"""Process volume mounts based on configuration.
Returns:
A dictionary mapping host paths to container bind mounts with their modes.
"""
# Initialize volumes dictionary
volumes: dict[str, dict[str, str]] = {}
# Process volumes (comma-delimited)
if self.config.sandbox.volumes is not None:
# Handle multiple mounts with comma delimiter
mounts = self.config.sandbox.volumes.split(',')
for mount in mounts:
parts = mount.split(':')
if len(parts) >= 2:
# Support both bind mounts (absolute paths) and Docker named volumes.
# Named volume syntax:
# volume:<name> (explicit)
# <name> (implicit when not starting with '/')
raw_host_part = parts[0]
if raw_host_part.startswith('volume:'):
host_path = raw_host_part.split('volume:', 1)[1]
elif not os.path.isabs(raw_host_part):
host_path = raw_host_part # treat as named volume
else:
host_path = os.path.abspath(raw_host_part)
container_path = parts[1]
# Default mode is 'rw' if not specified
mount_mode = parts[2] if len(parts) > 2 else 'rw'
# Skip overlay mounts here; they will be handled separately via Mount objects
if 'overlay' in mount_mode:
continue
volumes[host_path] = {
'bind': container_path,
'mode': mount_mode,
}
logger.debug(
f'Mount dir (sandbox.volumes): {host_path} to {container_path} with mode: {mount_mode}'
)
# Legacy mounting with workspace_* parameters
elif (
self.config.workspace_mount_path is not None
and self.config.workspace_mount_path_in_sandbox is not None
):
mount_mode = 'rw' # Default mode
# e.g. result would be: {"/home/user/openhands/workspace": {'bind': "/workspace", 'mode': 'rw'}}
# Add os.path.abspath() here so that relative paths can be used when workspace_mount_path is configured in config.toml
volumes[os.path.abspath(self.config.workspace_mount_path)] = {
'bind': self.config.workspace_mount_path_in_sandbox,
'mode': mount_mode,
}
logger.debug(
f'Mount dir (legacy): {self.config.workspace_mount_path} with mode: {mount_mode}'
)
return volumes
def _process_overlay_mounts(self) -> list[Mount]:
"""Process overlay mounts specified in sandbox.volumes with mode containing 'overlay'.
Returns:
List of docker.types.Mount objects configured with overlay driver providing
read-only lowerdir with per-container copy-on-write upper/work layers.
"""
overlay_mounts: list[Mount] = []
# No volumes configured
if self.config.sandbox.volumes is None:
return overlay_mounts
# Base directory for overlay upper/work layers from env var
overlay_base = os.environ.get('SANDBOX_VOLUME_OVERLAYS')
if not overlay_base:
# If no base path provided, skip overlay processing
return overlay_mounts
os.makedirs(overlay_base, exist_ok=True)
mount_specs = self.config.sandbox.volumes.split(',')
for idx, mount_spec in enumerate(mount_specs):
parts = mount_spec.split(':')
if len(parts) < 2:
continue
host_path = os.path.abspath(parts[0])
container_path = parts[1]
mount_mode = parts[2] if len(parts) > 2 else 'rw'
# Only consider overlay mounts for host-bind paths (absolute)
if (not os.path.isabs(parts[0])) or ('overlay' not in mount_mode):
continue
# Prepare upper and work directories unique to this container and mount
overlay_dir = os.path.join(overlay_base, self.container_name, f'{idx}')
upper_dir = os.path.join(overlay_dir, 'upper')
work_dir = os.path.join(overlay_dir, 'work')
os.makedirs(upper_dir, exist_ok=True)
os.makedirs(work_dir, exist_ok=True)
driver_cfg = DriverConfig(
name='local',
options={
'type': 'overlay',
'device': 'overlay',
'o': f'lowerdir={host_path},upperdir={upper_dir},workdir={work_dir}',
},
)
mount = Mount(
target=container_path,
source='', # Anonymous volume
type='volume',
labels={
'app': 'openhands',
'role': 'worker',
'container': self.container_name,
},
driver_config=driver_cfg,
)
overlay_mounts.append(mount)
return overlay_mounts
def init_container(self) -> None:
self.log('debug', 'Preparing to start container...')
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
# Allocate host port with locking to prevent race conditions
self._host_port, self._host_port_lock = self._find_available_port_with_lock(
EXECUTION_SERVER_PORT_RANGE
)
self._container_port = self._host_port
# Use the configured vscode_port if provided, otherwise find an available port
if self.config.sandbox.vscode_port:
self._vscode_port = self.config.sandbox.vscode_port
self._vscode_port_lock = None # No lock needed for configured port
else:
self._vscode_port, self._vscode_port_lock = (
self._find_available_port_with_lock(VSCODE_PORT_RANGE)
)
# Allocate app ports with locking
app_port_1, app_lock_1 = self._find_available_port_with_lock(APP_PORT_RANGE_1)
app_port_2, app_lock_2 = self._find_available_port_with_lock(APP_PORT_RANGE_2)
self._app_ports = [app_port_1, app_port_2]
self._app_port_locks = [
lock for lock in [app_lock_1, app_lock_2] if lock is not None
]
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
use_host_network = self.config.sandbox.use_host_network
network_mode: typing.Literal['host'] | None = (
'host' if use_host_network else None
)
# Initialize port mappings
port_mapping: dict[str, list[dict[str, str]]] | None = None
if not use_host_network:
port_mapping = {
f'{self._container_port}/tcp': [
{
'HostPort': str(self._host_port),
'HostIp': self.config.sandbox.runtime_binding_address,
}
],
}
if self.vscode_enabled:
port_mapping[f'{self._vscode_port}/tcp'] = [
{
'HostPort': str(self._vscode_port),
'HostIp': self.config.sandbox.runtime_binding_address,
}
]
for port in self._app_ports:
port_mapping[f'{port}/tcp'] = [
{
'HostPort': str(port),
'HostIp': self.config.sandbox.runtime_binding_address,
}
]
else:
self.log(
'warn',
'Using host network mode. If you are using MacOS, please make sure you have the latest version of Docker Desktop and enabled host network feature: https://docs.docker.com/network/drivers/host/#docker-desktop',
)
# Combine environment variables
environment = dict(**self.initial_env_vars)
environment.update(
{
'port': str(self._container_port),
'PYTHONUNBUFFERED': '1',
# Passing in the ports means nested runtimes do not come up with their own ports!
'VSCODE_PORT': str(self._vscode_port),
'APP_PORT_1': str(self._app_ports[0]),
'APP_PORT_2': str(self._app_ports[1]),
'OPENHANDS_SESSION_ID': str(self.sid),
'PIP_BREAK_SYSTEM_PACKAGES': '1',
}
)
if self.config.debug or DEBUG:
environment['DEBUG'] = 'true'
# Pass DOCKER_HOST_ADDR to spawned containers if it exists
if os.environ.get('DOCKER_HOST_ADDR'):
environment['DOCKER_HOST_ADDR'] = os.environ['DOCKER_HOST_ADDR']
# also update with runtime_startup_env_vars
environment.update(self.config.sandbox.runtime_startup_env_vars)
self.log('debug', f'Workspace Base: {self.config.workspace_base}')
# Process volumes for mounting
volumes = self._process_volumes()
# If no volumes were configured, set to None
if not volumes:
logger.debug(
'Mount dir is not set, will not mount the workspace directory to the container'
)
volumes = {} # Empty dict instead of None to satisfy mypy
self.log(
'debug',
f'Sandbox workspace: {self.config.workspace_mount_path_in_sandbox}',
)
command = self.get_action_execution_server_startup_command()
self.log('info', f'Starting server with command: {command}')
if self.config.sandbox.enable_gpu:
gpu_ids = self.config.sandbox.cuda_visible_devices
if gpu_ids is None:
device_requests = [
docker.types.DeviceRequest(capabilities=[['gpu']], count=-1)
]
else:
device_requests = [
docker.types.DeviceRequest(
capabilities=[['gpu']],
device_ids=[str(i) for i in gpu_ids.split(',')],
)
]
else:
device_requests = None
try:
if self.runtime_container_image is None:
raise ValueError('Runtime container image is not set')
# Process overlay mounts (read-only lower with per-container COW)
overlay_mounts = self._process_overlay_mounts()
self.container = self.docker_client.containers.run(
self.runtime_container_image,
# Use Docker's tini init process to ensure proper signal handling and reaping of
# zombie child processes.
init=True,
command=command,
# Override the default 'bash' entrypoint because the command is a binary.
entrypoint=[],
network_mode=network_mode,
ports=port_mapping,
working_dir='/openhands/code/', # do not change this!
name=self.container_name,
detach=True,
environment=environment,
volumes=volumes, # type: ignore
mounts=overlay_mounts, # type: ignore
device_requests=device_requests,
**(self.config.sandbox.docker_runtime_kwargs or {}),
)
self.log('debug', f'Container started. Server url: {self.api_url}')
self.set_runtime_status(RuntimeStatus.RUNTIME_STARTED)
except Exception as e:
self.log(
'error',
f'Error: Instance {self.container_name} FAILED to start container!\n',
)
self.close()
raise e
def _attach_to_container(self) -> None:
self.container = self.docker_client.containers.get(self.container_name)
if self.container.status == 'exited':
self.container.start()
config = self.container.attrs['Config']
for env_var in config['Env']:
if env_var.startswith('port='):
self._host_port = int(env_var.split('port=')[1])
self._container_port = self._host_port
elif env_var.startswith('VSCODE_PORT='):
self._vscode_port = int(env_var.split('VSCODE_PORT=')[1])
self._app_ports = []
exposed_ports = config.get('ExposedPorts')
if exposed_ports:
for exposed_port in exposed_ports.keys():
exposed_port = int(exposed_port.split('/tcp')[0])
if (
exposed_port != self._host_port
and exposed_port != self._vscode_port
):
self._app_ports.append(exposed_port)
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
self.log(
'debug',
f'attached to container: {self.container_name} {self._container_port} {self.api_url}',
)
@tenacity.retry(
stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
retry=tenacity.retry_if_exception(_is_retryablewait_until_alive_error),
reraise=True,
wait=tenacity.wait_fixed(2),
)
def wait_until_alive(self) -> None:
try:
container = self.docker_client.containers.get(self.container_name)
if container.status == 'exited':
raise AgentRuntimeDisconnectedError(
f'Container {self.container_name} has exited.'
)
except docker.errors.NotFound:
raise AgentRuntimeNotFoundError(
f'Container {self.container_name} not found.'
)
self.check_if_alive()
def close(self, rm_all_containers: bool | None = None) -> None:
"""Closes the DockerRuntime and associated objects
Parameters:
- rm_all_containers (bool): Whether to remove all containers with the 'openhands-sandbox-' prefix
"""
super().close()
if self.log_streamer:
self.log_streamer.close()
if rm_all_containers is None:
rm_all_containers = self.config.sandbox.rm_all_containers
if self.config.sandbox.keep_runtime_alive or self.attach_to_existing:
return
close_prefix = (
CONTAINER_NAME_PREFIX if rm_all_containers else self.container_name
)
stop_all_containers(close_prefix)
self._release_port_locks()
def _release_port_locks(self) -> None:
"""Release all acquired port locks."""
if self._host_port_lock:
self._host_port_lock.release()
self._host_port_lock = None
logger.debug(f'Released host port lock for port {self._host_port}')
if self._vscode_port_lock:
self._vscode_port_lock.release()
self._vscode_port_lock = None
logger.debug(f'Released VSCode port lock for port {self._vscode_port}')
for i, lock in enumerate(self._app_port_locks):
if lock:
lock.release()
logger.debug(
f'Released app port lock for port {self._app_ports[i] if i < len(self._app_ports) else "unknown"}'
)
self._app_port_locks.clear()
def _is_port_in_use_docker(self, port: int) -> bool:
containers = self.docker_client.containers.list()
for container in containers:
container_ports = container.ports
if str(port) in str(container_ports):
return True
return False
def _find_available_port_with_lock(
self, port_range: tuple[int, int], max_attempts: int = 5
) -> tuple[int, PortLock | None]:
"""Find an available port with race condition protection.
This method uses file-based locking to prevent multiple workers
from allocating the same port simultaneously.
Args:
port_range: Tuple of (min_port, max_port)
max_attempts: Maximum number of attempts to find a port
Returns:
Tuple of (port_number, port_lock) where port_lock may be None if locking failed
"""
# Try to find and lock an available port
result = find_available_port_with_lock(
min_port=port_range[0],
max_port=port_range[1],
max_attempts=max_attempts,
bind_address='0.0.0.0',
lock_timeout=1.0,
)
if result is None:
# Fallback to original method if port locking fails
logger.warning(
f'Port locking failed for range {port_range}, falling back to original method'
)
port = port_range[1]
for _ in range(max_attempts):
port = find_available_tcp_port(port_range[0], port_range[1])
if not self._is_port_in_use_docker(port):
return port, None
return port, None
port, port_lock = result
# Additional check with Docker to ensure port is not in use
if self._is_port_in_use_docker(port):
port_lock.release()
# Try again with a different port
logger.debug(f'Port {port} is in use by Docker, trying again')
return self._find_available_port_with_lock(port_range, max_attempts - 1)
return port, port_lock
def _find_available_port(
self, port_range: tuple[int, int], max_attempts: int = 5
) -> int:
"""Find an available port (legacy method for backward compatibility)."""
port, _ = self._find_available_port_with_lock(port_range, max_attempts)
return port
@property
def vscode_url(self) -> str | None:
token = super().get_vscode_token()
if not token:
return None
vscode_url = f'http://localhost:{self._vscode_port}/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
return vscode_url
@property
def web_hosts(self) -> dict[str, int]:
hosts: dict[str, int] = {}
host_addr = os.environ.get('DOCKER_HOST_ADDR', 'localhost')
for port in self._app_ports:
hosts[f'http://{host_addr}:{port}'] = port
return hosts
def pause(self) -> None:
"""Pause the runtime by stopping the container.
This is different from container.stop() as it ensures environment variables are properly preserved.
"""
if not self.container:
raise RuntimeError('Container not initialized')
# First, ensure all environment variables are properly persisted in .bashrc
# This is already handled by add_env_vars in base.py
# Stop the container
self.container.stop()
self.log('debug', f'Container {self.container_name} paused')
def resume(self) -> None:
"""Resume the runtime by starting the container.
This is different from container.start() as it ensures environment variables are properly restored.
"""
if not self.container:
raise RuntimeError('Container not initialized')
# Start the container
self.container.start()
self.log('debug', f'Container {self.container_name} resumed')
# Wait for the container to be ready
self.wait_until_alive()
@classmethod
async def delete(cls, conversation_id: str) -> None:
docker_client = cls._init_docker_client()
try:
container_name = CONTAINER_NAME_PREFIX + conversation_id
container = docker_client.containers.get(container_name)
container.remove(force=True)
except docker.errors.APIError:
pass
except docker.errors.NotFound:
pass
finally:
docker_client.close()
def get_action_execution_server_startup_command(self) -> list[str]:
return get_action_execution_server_startup_command(
server_port=self._container_port,
plugins=self.plugins,
app_config=self.config,
main_module=self.main_module,
)
+141
View File
@@ -0,0 +1,141 @@
# OpenHands Kubernetes Runtime
This directory contains the Kubernetes runtime implementation for OpenHands, which allows the software to run on Kubernetes clusters for scalable and isolated execution environments.
## Local Development with KIND
For local development and testing, OpenHands provides a convenient setup using KIND (Kubernetes IN Docker) that creates a local Kubernetes cluster.
### Prerequisites
Before setting up the local Kubernetes environment, ensure you have the following tools installed:
1. **KIND (Kubernetes IN Docker)** - [Installation Guide](https://kind.sigs.k8s.io/docs/user/quick-start/)
2. **kubectl** - [Installation Guide](https://kubernetes.io/docs/tasks/tools/#kubectl)
3. **mirrord** - [Installation Guide](https://metalbear.co/mirrord/docs/overview/quick-start/#installation)
MirrorD is used for network mirroring allowing the locally running process to interact with the kubernetes cluster as if it were running inside of kubernetes.
4. **Docker or Podman** - Required for KIND to work
- Docker: Follow the official Docker installation guide for your platform
- Podman: [Installation Guide](https://podman.io/docs/installation)
### Configuration
To use the Kubernetes runtime, you need to configure OpenHands properly. The configuration is done through a TOML configuration file.
#### Required Configuration
Two configuration options are required to use the Kubernetes runtime:
1. **Runtime Type**: Set the runtime to use Kubernetes
```toml
[core]
runtime = "kubernetes"
```
2. **Runtime Container Image**: Specify the container image to use for the runtime environment
```toml
[sandbox]
runtime_container_image = "docker.openhands.dev/openhands/runtime:1.2-nikolaik"
```
#### Additional Kubernetes Options
OpenHands provides extensive configuration options for Kubernetes deployments under the `[kubernetes]` section. These options allow you to customize:
- Kubernetes namespace
- Persistent volume configuration
- Ingress and networking settings
- Runtime Pod Security settings
- Resource limits and requests
For a complete list of available Kubernetes configuration options, refer to the `[kubernetes]` section in the `config.template.toml` file in the repository root.
## Local Development Setup
### Quick Start
To set up and run OpenHands with the Kubernetes runtime locally:
First build the application with
```bash
make build
```
Then
```bash
make kind # target is stateless and will check for an existing kind cluster or make a new one if not present.
```
This command will:
1. **Check Dependencies**: Verify that `kind`, `kubectl`, and `mirrord` are installed
2. **Create KIND Cluster**: Create a local Kubernetes cluster named "local-hands" using the configuration in `kind/cluster.yaml`
3. **Deploy Infrastructure**: Apply Kubernetes manifests including:
- Ubuntu development pod for runtime execution
- Nginx ingress controller for HTTP routing
- RBAC configurations for proper permissions
4. **Setup Mirrord**: Install mirrord resources for development workflow
5. **Run Application**: Execute `make run` inside the mirrord environment
### Cluster Configuration
The KIND cluster is configured with:
- **Cluster Name**: `local-hands`
- **Node Configuration**: Single control-plane node
- **Port Mapping**: Host port 80 maps to container port 80 for nginx ingress
- **Base Image**: Ubuntu 22.04 for the development environment
### Infrastructure Components
The local setup includes several Kubernetes resources:
#### Development Environment
- **Deployment**: `ubuntu-dev` - Ubuntu 22.04 container for code execution
- **Service**: Exposes the development environment within the cluster
#### Ingress Controller (Nginx)
- **Namespace**: `ingress-nginx` - Dedicated namespace for ingress resources
- **Deployment**: `ingress-nginx-controller` - Handles HTTP routing and load balancing
- **Service**: LoadBalancer service for external access
- **ConfigMap**: Custom configuration for nginx controller
- **RBAC**: Roles and bindings for proper cluster permissions
#### Development Workflow
- **Mirrord Integration**: Allows running local development server while connecting to cluster resources
- **Port Forwarding**: Direct access to cluster services from localhost
### Usage
Once the environment is set up with `make kind`, the system will:
1. Wait for all deployments to be ready
2. Automatically start the OpenHands application using mirrord
3. Provide access to the application at http://127.0.0.1:3000/
The mirrord integration allows you to develop locally while your application has access to the Kubernetes cluster resources, providing a seamless development experience that mirrors production behavior.
### Troubleshooting
If you encounter issues:
1. **Check cluster status**: `kubectl get nodes`
2. **Verify deployments**: `kubectl get deployments --all-namespaces`
3. **Check ingress**: `kubectl get ingress --all-namespaces`
4. **View logs**: `kubectl logs -l app=ubuntu-dev`
To clean up the environment:
```bash
kind delete cluster --name local-hands
```
@@ -0,0 +1,759 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
from functools import lru_cache
from typing import Callable
from uuid import UUID
import tenacity
import yaml
from kubernetes import client, config
from kubernetes.client.models import (
V1Container,
V1ContainerPort,
V1EnvVar,
V1HTTPIngressPath,
V1HTTPIngressRuleValue,
V1Ingress,
V1IngressBackend,
V1IngressRule,
V1IngressServiceBackend,
V1IngressSpec,
V1IngressTLS,
V1ObjectMeta,
V1PersistentVolumeClaim,
V1PersistentVolumeClaimSpec,
V1PersistentVolumeClaimVolumeSource,
V1Pod,
V1PodSpec,
V1ResourceRequirements,
V1SecurityContext,
V1Service,
V1ServiceBackendPort,
V1ServicePort,
V1ServiceSpec,
V1Toleration,
V1Volume,
V1VolumeMount,
)
from openhands.core.config import OpenHandsConfig
from openhands.core.exceptions import (
AgentRuntimeDisconnectedError,
AgentRuntimeNotFoundError,
)
from openhands.core.logger import DEBUG
from openhands.core.logger import openhands_logger as logger
from openhands.events import EventStream
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.llm.llm_registry import LLMRegistry
from openhands.runtime.impl.action_execution.action_execution_client import (
ActionExecutionClient,
)
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.runtime_status import RuntimeStatus
from openhands.runtime.utils.command import get_action_execution_server_startup_command
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.shutdown_listener import add_shutdown_listener
from openhands.utils.tenacity_stop import stop_if_should_exit
POD_NAME_PREFIX = 'openhands-runtime-'
POD_LABEL = 'openhands-runtime'
class KubernetesRuntime(ActionExecutionClient):
"""A Kubernetes runtime for OpenHands that works with Kind.
This runtime creates pods in a Kubernetes cluster to run the agent code.
It uses the Kubernetes Python client to create and manage the pods.
Args:
config (OpenHandsConfig): The application configuration.
event_stream (EventStream): The event stream to subscribe to.
sid (str, optional): The session ID. Defaults to 'default'.
plugins (list[PluginRequirement] | None, optional): List of plugin requirements. Defaults to None.
env_vars (dict[str, str] | None, optional): Environment variables to set. Defaults to None.
status_callback (Callable | None, optional): Callback for status updates. Defaults to None.
attach_to_existing (bool, optional): Whether to attach to an existing pod. Defaults to False.
headless_mode (bool, optional): Whether to run in headless mode. Defaults to True.
"""
_shutdown_listener_id: UUID | None = None
_namespace: str = ''
def __init__(
self,
config: OpenHandsConfig,
event_stream: EventStream,
llm_registry: LLMRegistry,
sid: str = 'default',
plugins: list[PluginRequirement] | None = None,
env_vars: dict[str, str] | None = None,
status_callback: Callable | None = None,
attach_to_existing: bool = False,
headless_mode: bool = True,
user_id: str | None = None,
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
):
if not KubernetesRuntime._shutdown_listener_id:
KubernetesRuntime._shutdown_listener_id = add_shutdown_listener(
lambda: KubernetesRuntime._cleanup_k8s_resources(
namespace=self._k8s_namespace,
remove_pvc=True,
conversation_id=self.sid,
) # this is when you ctrl+c.
)
self.config = config
self._runtime_initialized: bool = False
self.status_callback = status_callback
# Load and validate Kubernetes configuration
if self.config.kubernetes is None:
raise ValueError(
'Kubernetes configuration is required when using KubernetesRuntime. '
'Please add a [kubernetes] section to your configuration.'
)
self._k8s_config = self.config.kubernetes
self._k8s_namespace = self._k8s_config.namespace
KubernetesRuntime._namespace = self._k8s_namespace
# Initialize ports with default values in the required range
self._container_port = 8080 # Default internal container port
self._vscode_port = 8081 # Default VSCode port.
self._app_ports: list[int] = [
30082,
30083,
] # Default app ports in valid range # The agent prefers these when exposing an application.
self.k8s_client, self.k8s_networking_client = self._init_kubernetes_client()
self.pod_image = self.config.sandbox.runtime_container_image
if not self.pod_image:
# If runtime_container_image isn't set, use the base_container_image as a fallback
self.pod_image = self.config.sandbox.base_container_image
self.pod_name = POD_NAME_PREFIX + sid
# Initialize the API URL with the initial port value
self.k8s_local_url = f'http://{self._get_svc_name(self.pod_name)}.{self._k8s_namespace}.svc.cluster.local'
self.api_url = f'{self.k8s_local_url}:{self._container_port}'
super().__init__(
config,
event_stream,
llm_registry,
sid,
plugins,
env_vars,
status_callback,
attach_to_existing,
headless_mode,
user_id,
git_provider_tokens,
)
@staticmethod
def _get_svc_name(pod_name: str) -> str:
"""Get the service name for the pod."""
return f'{pod_name}-svc'
@staticmethod
def _get_vscode_svc_name(pod_name: str) -> str:
"""Get the VSCode service name for the pod."""
return f'{pod_name}-svc-code'
@staticmethod
def _get_vscode_ingress_name(pod_name: str) -> str:
"""Get the VSCode ingress name for the pod."""
return f'{pod_name}-ingress-code'
@staticmethod
def _get_vscode_tls_secret_name(pod_name: str) -> str:
"""Get the TLS secret name for the VSCode ingress."""
return f'{pod_name}-tls-secret'
@staticmethod
def _get_pvc_name(pod_name: str) -> str:
"""Get the PVC name for the pod."""
return f'{pod_name}-pvc'
@staticmethod
def _get_pod_name(sid: str) -> str:
"""Get the pod name for the session."""
return POD_NAME_PREFIX + sid
@property
def action_execution_server_url(self):
return self.api_url
@property
def node_selector(self) -> dict[str, str] | None:
if (
not self._k8s_config.node_selector_key
or not self._k8s_config.node_selector_val
):
return None
return {self._k8s_config.node_selector_key: self._k8s_config.node_selector_val}
@property
def tolerations(self) -> list[V1Toleration] | None:
if not self._k8s_config.tolerations_yaml:
return None
tolerations_yaml_str = self._k8s_config.tolerations_yaml
tolerations = []
try:
tolerations_data = yaml.safe_load(tolerations_yaml_str)
if isinstance(tolerations_data, list):
for toleration in tolerations_data:
tolerations.append(V1Toleration(**toleration))
else:
logger.error(
f'Invalid tolerations format. Should be type list: {tolerations_yaml_str}. Expected a list.'
)
return None
except yaml.YAMLError as e:
logger.error(
f'Error parsing tolerations YAML: {tolerations_yaml_str}. Error: {e}'
)
return None
return tolerations
async def connect(self):
"""Connect to the runtime by creating or attaching to a pod."""
self.log('info', f'Connecting to runtime with conversation ID: {self.sid}')
self.log('info', f'self._attach_to_existing: {self.attach_to_existing}')
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
self.log('info', f'Using API URL {self.api_url}')
try:
await call_sync_from_async(self._attach_to_pod)
except client.rest.ApiException as e:
# we are not set to attach to existing, ignore error and init k8s resources.
if self.attach_to_existing:
self.log(
'error',
f'Pod {self.pod_name} not found or cannot connect to it.',
)
raise AgentRuntimeDisconnectedError from e
self.log('info', f'Starting runtime with image: {self.pod_image}')
try:
await call_sync_from_async(self._init_k8s_resources)
self.log(
'info',
f'Pod started: {self.pod_name}. VSCode URL: {self.vscode_url}',
)
except Exception as init_error:
self.log('error', f'Failed to initialize k8s resources: {init_error}')
raise AgentRuntimeNotFoundError(
f'Failed to initialize kubernetes resources: {init_error}'
) from init_error
if not self.attach_to_existing:
self.log('info', 'Waiting for pod to become ready ...')
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
try:
await call_sync_from_async(self._wait_until_ready)
except Exception as alive_error:
self.log('error', f'Failed to connect to runtime: {alive_error}')
self.set_runtime_status(
RuntimeStatus.ERROR_RUNTIME_DISCONNECTED,
f'Failed to connect to runtime: {alive_error}',
)
raise AgentRuntimeDisconnectedError(
f'Failed to connect to runtime: {alive_error}'
) from alive_error
if not self.attach_to_existing:
self.log('info', 'Runtime is ready.')
if not self.attach_to_existing:
await call_sync_from_async(self.setup_initial_env)
self.log(
'info',
f'Pod initialized with plugins: {[plugin.name for plugin in self.plugins]}. VSCode URL: {self.vscode_url}',
)
if not self.attach_to_existing:
self.set_runtime_status(RuntimeStatus.READY)
self._runtime_initialized = True
def _attach_to_pod(self):
"""Attach to an existing pod."""
try:
pod = self.k8s_client.read_namespaced_pod(
name=self.pod_name, namespace=self._k8s_namespace
)
if pod.status.phase != 'Running':
try:
self._wait_until_ready()
except TimeoutError:
raise AgentRuntimeDisconnectedError(
f'Pod {self.pod_name} exists but failed to become ready.'
)
self.log('info', f'Successfully attached to pod {self.pod_name}')
return True
except client.rest.ApiException as e:
self.log('error', f'Failed to attach to pod: {e}')
raise
@tenacity.retry(
stop=tenacity.stop_after_delay(300) | stop_if_should_exit(),
retry=tenacity.retry_if_exception_type(TimeoutError),
reraise=True,
wait=tenacity.wait_fixed(2),
)
def _wait_until_ready(self):
"""Wait until the runtime server is alive by checking the pod status in Kubernetes."""
self.log('info', f'Checking if pod {self.pod_name} is ready in Kubernetes')
pod = self.k8s_client.read_namespaced_pod(
name=self.pod_name, namespace=self._k8s_namespace
)
if pod.status.phase == 'Running' and pod.status.conditions:
for condition in pod.status.conditions:
if condition.type == 'Ready' and condition.status == 'True':
self.log('info', f'Pod {self.pod_name} is ready!')
return True # Exit the function if the pod is ready
self.log(
'info',
f'Pod {self.pod_name} is not ready yet. Current phase: {pod.status.phase}',
)
raise TimeoutError(f'Pod {self.pod_name} is not in Running state yet.')
@staticmethod
@lru_cache(maxsize=1)
def _init_kubernetes_client() -> tuple[client.CoreV1Api, client.NetworkingV1Api]:
"""Initialize the Kubernetes client."""
try:
config.load_incluster_config() # Even local usage with mirrord technically uses an incluster config.
return client.CoreV1Api(), client.NetworkingV1Api()
except Exception as ex:
logger.error(
'Failed to initialize Kubernetes client. Make sure you have kubectl configured correctly or are running in a Kubernetes cluster.',
)
raise ex
@staticmethod
def _cleanup_k8s_resources(
namespace: str, remove_pvc: bool = False, conversation_id: str = ''
):
"""Clean up Kubernetes resources with our prefix in the namespace.
:param remove_pvc: If True, also remove persistent volume claims (defaults to False).
"""
try:
k8s_api, k8s_networking_api = KubernetesRuntime._init_kubernetes_client()
pod_name = KubernetesRuntime._get_pod_name(conversation_id)
service_name = KubernetesRuntime._get_svc_name(pod_name)
vscode_service_name = KubernetesRuntime._get_vscode_svc_name(pod_name)
ingress_name = KubernetesRuntime._get_vscode_ingress_name(pod_name)
pvc_name = KubernetesRuntime._get_pvc_name(pod_name)
try:
if remove_pvc:
# Delete PVC if requested
k8s_api.delete_namespaced_persistent_volume_claim(
name=pvc_name,
namespace=namespace,
body=client.V1DeleteOptions(),
)
logger.info(f'Deleted PVC {pvc_name}')
k8s_api.delete_namespaced_pod(
name=pod_name,
namespace=namespace,
body=client.V1DeleteOptions(),
)
logger.info(f'Deleted pod {pod_name}')
k8s_api.delete_namespaced_service(
name=service_name,
namespace=namespace,
)
logger.info(f'Deleted service {service_name}')
# Delete the vs code service
k8s_api.delete_namespaced_service(
name=vscode_service_name, namespace=namespace
)
logger.info(f'Deleted service {vscode_service_name}')
k8s_networking_api.delete_namespaced_ingress(
name=ingress_name, namespace=namespace
)
logger.info(f'Deleted ingress {ingress_name}')
except client.rest.ApiException:
# Service might not exist, ignore
pass
logger.info('Cleaned up Kubernetes resources')
except Exception as e:
logger.error(f'Error cleaning up k8s resources: {e}')
def _get_pvc_manifest(self):
"""Create a PVC manifest for the runtime pod."""
# Create PVC
pvc = V1PersistentVolumeClaim(
api_version='v1',
kind='PersistentVolumeClaim',
metadata=V1ObjectMeta(
name=self._get_pvc_name(self.pod_name), namespace=self._k8s_namespace
),
spec=V1PersistentVolumeClaimSpec(
access_modes=['ReadWriteOnce'],
resources=client.V1ResourceRequirements(
requests={'storage': self._k8s_config.pvc_storage_size}
),
storage_class_name=self._k8s_config.pvc_storage_class,
),
)
return pvc
def _get_vscode_service_manifest(self):
"""Create a service manifest for the VSCode server."""
vscode_service_spec = V1ServiceSpec(
selector={'app': POD_LABEL, 'session': self.sid},
type='ClusterIP',
ports=[
V1ServicePort(
port=self._vscode_port,
target_port='vscode',
name='code',
)
],
)
vscode_service = V1Service(
metadata=V1ObjectMeta(name=self._get_vscode_svc_name(self.pod_name)),
spec=vscode_service_spec,
)
return vscode_service
def _get_runtime_service_manifest(self):
"""Create a service manifest for the runtime pod execution-server."""
service_spec = V1ServiceSpec(
selector={'app': POD_LABEL, 'session': self.sid},
type='ClusterIP',
ports=[
V1ServicePort(
port=self._container_port,
target_port='http',
name='execution-server',
)
],
)
service = V1Service(
metadata=V1ObjectMeta(name=self._get_svc_name(self.pod_name)),
spec=service_spec,
)
return service
def _get_runtime_pod_manifest(self):
"""Create a pod manifest for the runtime sandbox."""
# Prepare environment variables
environment = [
V1EnvVar(name='port', value=str(self._container_port)),
V1EnvVar(name='PYTHONUNBUFFERED', value='1'),
V1EnvVar(name='VSCODE_PORT', value=str(self._vscode_port)),
]
if self.config.debug or DEBUG:
environment.append(V1EnvVar(name='DEBUG', value='true'))
# Add runtime startup env vars
for key, value in self.config.sandbox.runtime_startup_env_vars.items():
environment.append(V1EnvVar(name=key, value=value))
# Prepare volume mounts if workspace is configured
volume_mounts = [
V1VolumeMount(
name='workspace-volume',
mount_path=self.config.workspace_mount_path_in_sandbox,
),
]
volumes = [
V1Volume(
name='workspace-volume',
persistent_volume_claim=V1PersistentVolumeClaimVolumeSource(
claim_name=self._get_pvc_name(self.pod_name)
),
)
]
# Prepare container ports
container_ports = [
V1ContainerPort(container_port=self._container_port, name='http'),
]
if self.vscode_enabled:
container_ports.append(
V1ContainerPort(container_port=self._vscode_port, name='vscode')
)
for port in self._app_ports:
container_ports.append(V1ContainerPort(container_port=port))
# Define the readiness probe
health_check = client.V1Probe(
http_get=client.V1HTTPGetAction(
path='/alive',
port=self._container_port, # Or the port your application listens on
),
initial_delay_seconds=5, # Adjust as needed
period_seconds=10, # Adjust as needed
timeout_seconds=5, # Adjust as needed
success_threshold=1,
failure_threshold=3,
)
# Prepare command
# Entry point command for generated sandbox runtime pod.
command = get_action_execution_server_startup_command(
server_port=self._container_port,
plugins=self.plugins,
app_config=self.config,
override_user_id=0, # if we use the default of app_config.run_as_openhands then we cant edit files in vscode due to file perms.
override_username='root',
)
# Prepare resource requirements based on config
resources = V1ResourceRequirements(
limits={'memory': self._k8s_config.resource_memory_limit},
requests={
'cpu': self._k8s_config.resource_cpu_request,
'memory': self._k8s_config.resource_memory_request,
},
)
# Set security context for the container
security_context = V1SecurityContext(privileged=self._k8s_config.privileged)
# Create the container definition
container = V1Container(
name='runtime',
image=self.pod_image,
command=command,
env=environment,
ports=container_ports,
volume_mounts=volume_mounts,
working_dir='/openhands/code/',
resources=resources,
readiness_probe=health_check,
security_context=security_context,
)
# Create the pod definition
image_pull_secrets = None
if self._k8s_config.image_pull_secret:
image_pull_secrets = [
client.V1LocalObjectReference(name=self._k8s_config.image_pull_secret)
]
pod = V1Pod(
metadata=V1ObjectMeta(
name=self.pod_name, labels={'app': POD_LABEL, 'session': self.sid}
),
spec=V1PodSpec(
containers=[container],
volumes=volumes,
restart_policy='Never',
image_pull_secrets=image_pull_secrets,
node_selector=self.node_selector,
tolerations=self.tolerations,
),
)
return pod
def _get_vscode_ingress_manifest(self):
"""Create an ingress manifest for the VSCode server."""
tls = []
if self._k8s_config.ingress_tls_secret:
runtime_tls = V1IngressTLS(
hosts=[self.ingress_domain],
secret_name=self._k8s_config.ingress_tls_secret,
)
tls = [runtime_tls]
rules = [
V1IngressRule(
host=self.ingress_domain,
http=V1HTTPIngressRuleValue(
paths=[
V1HTTPIngressPath(
path='/',
path_type='Prefix',
backend=V1IngressBackend(
service=V1IngressServiceBackend(
port=V1ServiceBackendPort(
number=self._vscode_port,
),
name=self._get_vscode_svc_name(self.pod_name),
)
),
)
]
),
)
]
ingress_spec = V1IngressSpec(rules=rules, tls=tls)
ingress = V1Ingress(
api_version='networking.k8s.io/v1',
metadata=V1ObjectMeta(
name=self._get_vscode_ingress_name(self.pod_name),
annotations={
'external-dns.alpha.kubernetes.io/hostname': self.ingress_domain
},
),
spec=ingress_spec,
)
return ingress
def _pvc_exists(self):
"""Check if the PVC already exists."""
try:
pvc = self.k8s_client.read_namespaced_persistent_volume_claim(
name=self._get_pvc_name(self.pod_name), namespace=self._k8s_namespace
)
return pvc is not None
except client.rest.ApiException as e:
if e.status == 404:
return False
self.log('error', f'Error checking PVC existence: {e}')
def _init_k8s_resources(self):
"""Initialize the Kubernetes resources."""
self.log('info', 'Preparing to start pod...')
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
self.log('info', f'Runtime will be accessible at {self.api_url}')
pod = self._get_runtime_pod_manifest()
service = self._get_runtime_service_manifest()
vscode_service = self._get_vscode_service_manifest()
pvc_manifest = self._get_pvc_manifest()
ingress = self._get_vscode_ingress_manifest()
# Create the pod in Kubernetes
try:
if not self._pvc_exists():
# Create PVC if it doesn't exist
self.k8s_client.create_namespaced_persistent_volume_claim(
namespace=self._k8s_namespace, body=pvc_manifest
)
self.log('info', f'Created PVC {self._get_pvc_name(self.pod_name)}')
self.k8s_client.create_namespaced_pod(
namespace=self._k8s_namespace, body=pod
)
self.log('info', f'Created pod {self.pod_name}.')
# Create a service to expose the pod for external access
self.k8s_client.create_namespaced_service(
namespace=self._k8s_namespace, body=service
)
self.log('info', f'Created service {self._get_svc_name(self.pod_name)}')
# Create second service service for the vscode server.
self.k8s_client.create_namespaced_service(
namespace=self._k8s_namespace, body=vscode_service
)
self.log(
'info', f'Created service {self._get_vscode_svc_name(self.pod_name)}'
)
# create the vscode ingress.
self.k8s_networking_client.create_namespaced_ingress(
namespace=self._k8s_namespace, body=ingress
)
self.log(
'info',
f'Created ingress {self._get_vscode_ingress_name(self.pod_name)}',
)
# Wait for the pod to be running
self._wait_until_ready()
except client.rest.ApiException as e:
self.log('error', f'Failed to create pod and services: {e}')
raise
except RuntimeError as e:
self.log('error', f'Port forwarding failed: {e}')
raise
def close(self):
"""Close the runtime and clean up resources."""
# this is called when a single conversation question is answered or a tab is closed.
self.log(
'info',
f'Closing runtime and cleaning up resources for conersation ID: {self.sid}',
)
# Call parent class close method first
super().close()
# Return early if we should keep the runtime alive or if we're attaching to existing
if self.config.sandbox.keep_runtime_alive or self.attach_to_existing:
self.log(
'info', 'Keeping runtime alive due to configuration or attach mode'
)
return
try:
self._cleanup_k8s_resources(
namespace=self._k8s_namespace,
remove_pvc=False,
conversation_id=self.sid,
)
except Exception as e:
self.log('error', f'Error closing runtime: {e}')
@property
def ingress_domain(self) -> str:
"""Get the ingress domain for the runtime."""
return f'{self.sid}.{self._k8s_config.ingress_domain}'
@property
def vscode_url(self) -> str | None:
"""Get the URL for VSCode server if enabled."""
if not self.vscode_enabled:
return None
token = super().get_vscode_token()
if not token:
return None
protocol = 'https' if self._k8s_config.ingress_tls_secret else 'http'
vscode_url = f'{protocol}://{self.ingress_domain}/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
self.log('info', f'VSCode URL: {vscode_url}')
return vscode_url
@property
def web_hosts(self) -> dict[str, int]:
"""Get web hosts dict mapping for browser access."""
hosts = {}
for idx, port in enumerate(self._app_ports):
hosts[f'{self.k8s_local_url}:{port}'] = port
return hosts
@classmethod
async def delete(cls, conversation_id: str):
"""Delete resources associated with a conversation."""
# This is triggered when you actually do the delete in the UI on the conversation.
try:
cls._cleanup_k8s_resources(
namespace=cls._namespace,
remove_pvc=True,
conversation_id=conversation_id,
)
except Exception as e:
logger.error(
f'Error deleting resources for conversation {conversation_id}: {e}'
)
+12
View File
@@ -0,0 +1,12 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
"""Local runtime implementation."""
from openhands.runtime.impl.local.local_runtime import LocalRuntime
__all__ = ['LocalRuntime']
@@ -0,0 +1,843 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
"""This runtime runs the action_execution_server directly on the local machine without Docker."""
import os
import shutil
import subprocess
import sys
import tempfile
import threading
from dataclasses import dataclass
from typing import Callable
from urllib.parse import urlparse
import httpx
import tenacity
import openhands
from openhands.core.config import OpenHandsConfig
from openhands.core.exceptions import AgentRuntimeDisconnectedError
from openhands.core.logger import openhands_logger as logger
from openhands.events import EventStream
from openhands.events.action import (
Action,
)
from openhands.events.observation import (
Observation,
)
from openhands.events.serialization import event_to_dict, observation_from_dict
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.llm.llm_registry import LLMRegistry
from openhands.runtime.impl.action_execution.action_execution_client import (
ActionExecutionClient,
)
from openhands.runtime.impl.docker.docker_runtime import (
APP_PORT_RANGE_1,
APP_PORT_RANGE_2,
EXECUTION_SERVER_PORT_RANGE,
VSCODE_PORT_RANGE,
)
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.plugins.vscode import VSCodeRequirement
from openhands.runtime.runtime_status import RuntimeStatus
from openhands.runtime.utils import find_available_tcp_port
from openhands.runtime.utils.command import get_action_execution_server_startup_command
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.http_session import httpx_verify_option
from openhands.utils.tenacity_stop import stop_if_should_exit
DISABLE_VSCODE_PLUGIN = os.getenv('DISABLE_VSCODE_PLUGIN', 'false').lower() == 'true'
@dataclass
class ActionExecutionServerInfo:
"""Information about a running server process."""
process: subprocess.Popen
execution_server_port: int
vscode_port: int
app_ports: list[int]
log_thread: threading.Thread
log_thread_exit_event: threading.Event
temp_workspace: str | None
workspace_mount_path: str
# Global dictionary to track running server processes by session ID
_RUNNING_SERVERS: dict[str, ActionExecutionServerInfo] = {}
# Global list to track warm servers waiting for use
_WARM_SERVERS: list[ActionExecutionServerInfo] = []
def get_user_info() -> tuple[int, str | None]:
"""Get user ID and username in a cross-platform way."""
username = os.getenv('USER')
if sys.platform == 'win32':
# On Windows, we don't use user IDs the same way
# Return a default value that won't cause issues
return 1000, username
else:
# On Unix systems, use os.getuid()
return os.getuid(), username
def check_dependencies(code_repo_path: str, check_browser: bool) -> None:
ERROR_MESSAGE = 'Please follow the instructions in https://github.com/OpenHands/OpenHands/blob/main/Development.md to install OpenHands.'
if not os.path.exists(code_repo_path):
raise ValueError(
f'Code repo path {code_repo_path} does not exist. ' + ERROR_MESSAGE
)
# Check jupyter is installed
logger.debug('Checking dependencies: Jupyter')
output = subprocess.check_output(
[sys.executable, '-m', 'jupyter', '--version'],
text=True,
cwd=code_repo_path,
)
logger.debug(f'Jupyter output: {output}')
if 'jupyter' not in output.lower():
raise ValueError('Jupyter is not properly installed. ' + ERROR_MESSAGE)
# Check libtmux is installed (skip on Windows)
if sys.platform != 'win32':
logger.debug('Checking dependencies: libtmux')
import libtmux
server = libtmux.Server()
try:
session = server.new_session(session_name='test-session')
except Exception:
raise ValueError('tmux is not properly installed or available on the path.')
pane = session.active_pane
if pane:
pane.send_keys('echo "test"')
pane_output = '\n'.join(pane.cmd('capture-pane', '-p').stdout)
else:
pane_output = ''
session.kill()
if 'test' not in pane_output:
raise ValueError('libtmux is not properly installed. ' + ERROR_MESSAGE)
if check_browser:
logger.debug('Checking dependencies: browser')
from openhands.runtime.browser.browser_env import BrowserEnv
browser = BrowserEnv()
browser.close()
class LocalRuntime(ActionExecutionClient):
"""This runtime will run the action_execution_server directly on the local machine.
When receiving an event, it will send the event to the server via HTTP.
Args:
config (OpenHandsConfig): The application configuration.
event_stream (EventStream): The event stream to subscribe to.
sid (str, optional): The session ID. Defaults to 'default'.
plugins (list[PluginRequirement] | None, optional): list of plugin requirements. Defaults to None.
env_vars (dict[str, str] | None, optional): Environment variables to set. Defaults to None.
"""
def __init__(
self,
config: OpenHandsConfig,
event_stream: EventStream,
llm_registry: LLMRegistry,
sid: str = 'default',
plugins: list[PluginRequirement] | None = None,
env_vars: dict[str, str] | None = None,
status_callback: Callable[[str, RuntimeStatus, str], None] | None = None,
attach_to_existing: bool = False,
headless_mode: bool = True,
user_id: str | None = None,
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
) -> None:
self.is_windows = sys.platform == 'win32'
if self.is_windows:
logger.warning(
'Running on Windows - some features that require tmux will be limited. '
'For full functionality, please consider using WSL or Docker runtime.'
)
self.config = config
self._user_id, self._username = get_user_info()
logger.warning(
'Initializing LocalRuntime. WARNING: NO SANDBOX IS USED. '
'This is an experimental feature, please report issues to https://github.com/OpenHands/OpenHands/issues. '
'`run_as_openhands` will be ignored since the current user will be used to launch the server. '
'We highly recommend using a sandbox (eg. DockerRuntime) unless you '
'are running in a controlled environment.\n'
f'User ID: {self._user_id}. '
f'Username: {self._username}.'
)
# Initialize these values to be set in connect()
self._temp_workspace: str | None = None
self._execution_server_port = -1
self._vscode_port = -1
self._app_ports: list[int] = []
self.api_url = (
f'{self.config.sandbox.local_runtime_url}:{self._execution_server_port}'
)
self.status_callback = status_callback
self.server_process: subprocess.Popen[str] | None = None
self.action_semaphore = threading.Semaphore(1) # Ensure one action at a time
self._log_thread_exit_event = threading.Event() # Add exit event
# Update env vars
if self.config.sandbox.runtime_startup_env_vars:
os.environ.update(self.config.sandbox.runtime_startup_env_vars)
# Initialize the action_execution_server
super().__init__(
config,
event_stream,
llm_registry,
sid,
plugins,
env_vars,
status_callback,
attach_to_existing,
headless_mode,
user_id,
git_provider_tokens,
)
# If there is an API key in the environment we use this in requests to the runtime
session_api_key = os.getenv('SESSION_API_KEY')
self._session_api_key: str | None = None
if session_api_key:
self.session.headers['X-Session-API-Key'] = session_api_key
self._session_api_key = session_api_key
@property
def session_api_key(self) -> str | None:
return self._session_api_key
@property
def action_execution_server_url(self) -> str:
return self.api_url
async def connect(self) -> None:
"""Start the action_execution_server on the local machine or connect to an existing one."""
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
# Get environment variables for warm server configuration
desired_num_warm_servers = int(os.getenv('DESIRED_NUM_WARM_SERVERS', '0'))
# Check if there's already a server running for this session ID
if self.sid in _RUNNING_SERVERS:
self.log('info', f'Connecting to existing server for session {self.sid}')
server_info = _RUNNING_SERVERS[self.sid]
self.server_process = server_info.process
self._execution_server_port = server_info.execution_server_port
self._log_thread = server_info.log_thread
self._log_thread_exit_event = server_info.log_thread_exit_event
self._vscode_port = server_info.vscode_port
self._app_ports = server_info.app_ports
self._temp_workspace = server_info.temp_workspace
self.config.workspace_mount_path_in_sandbox = (
server_info.workspace_mount_path
)
self.api_url = (
f'{self.config.sandbox.local_runtime_url}:{self._execution_server_port}'
)
elif self.attach_to_existing:
# If we're supposed to attach to an existing server but none exists, raise an error
self.log('error', f'No existing server found for session {self.sid}')
raise AgentRuntimeDisconnectedError(
f'No existing server found for session {self.sid}'
)
else:
# Set up workspace directory
# For local runtime, prefer a stable host path over /workspace defaults.
if (
self.config.workspace_base is None
and self.config.runtime
and self.config.runtime.lower() == 'local'
):
env_base = os.getenv('LOCAL_WORKSPACE_BASE')
if env_base:
self.config.workspace_base = os.path.abspath(env_base)
else:
self.config.workspace_base = os.path.abspath(
os.path.join(os.getcwd(), 'workspace', 'local')
)
if self.config.workspace_base is not None:
os.makedirs(self.config.workspace_base, exist_ok=True)
logger.warning(
f'Workspace base path is set to {self.config.workspace_base}. '
'It will be used as the path for the agent to run in. '
'Be careful, the agent can EDIT files in this directory!'
)
self.config.workspace_mount_path_in_sandbox = self.config.workspace_base
self._temp_workspace = None
else:
# A temporary directory is created for the agent to run in
logger.warning(
'Workspace base path is NOT set. Agent will run in a temporary directory.'
)
self._temp_workspace = tempfile.mkdtemp(
prefix=f'openhands_workspace_{self.sid}',
)
self.config.workspace_mount_path_in_sandbox = self._temp_workspace
logger.info(
f'Using workspace directory: {self.config.workspace_mount_path_in_sandbox}'
)
# Check if we have a warm server available
warm_server_available = False
if _WARM_SERVERS and not self.attach_to_existing:
try:
# Pop a warm server from the list
self.log('info', 'Using a warm server')
server_info = _WARM_SERVERS.pop(0)
# Use the warm server
self.server_process = server_info.process
self._execution_server_port = server_info.execution_server_port
self._log_thread = server_info.log_thread
self._log_thread_exit_event = server_info.log_thread_exit_event
self._vscode_port = server_info.vscode_port
self._app_ports = server_info.app_ports
# We need to clean up the warm server's temp workspace and create a new one
if server_info.temp_workspace:
shutil.rmtree(server_info.temp_workspace)
# Create a new temp workspace for this session
if (
self._temp_workspace is None
and self.config.workspace_base is None
):
self._temp_workspace = tempfile.mkdtemp(
prefix=f'openhands_workspace_{self.sid}',
)
self.config.workspace_mount_path_in_sandbox = (
self._temp_workspace
)
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._execution_server_port}'
# Store the server process in the global dictionary with the new workspace
_RUNNING_SERVERS[self.sid] = ActionExecutionServerInfo(
process=self.server_process,
execution_server_port=self._execution_server_port,
vscode_port=self._vscode_port,
app_ports=self._app_ports,
log_thread=self._log_thread,
log_thread_exit_event=self._log_thread_exit_event,
temp_workspace=self._temp_workspace,
workspace_mount_path=self.config.workspace_mount_path_in_sandbox,
)
warm_server_available = True
except IndexError:
# No warm servers available
self.log('info', 'No warm servers available, starting a new server')
warm_server_available = False
except Exception as e:
# Error using warm server
self.log('error', f'Error using warm server: {e}')
warm_server_available = False
# If no warm server is available, start a new one
if not warm_server_available:
# Create a new server
server_info, api_url = _create_server(
config=self.config,
plugins=self.plugins,
workspace_prefix=self.sid,
)
# Set instance variables
self.server_process = server_info.process
self._execution_server_port = server_info.execution_server_port
self._vscode_port = server_info.vscode_port
self._app_ports = server_info.app_ports
self._log_thread = server_info.log_thread
self._log_thread_exit_event = server_info.log_thread_exit_event
# We need to use the existing temp workspace, not the one created by _create_server
if (
server_info.temp_workspace
and server_info.temp_workspace != self._temp_workspace
):
shutil.rmtree(server_info.temp_workspace)
self.api_url = api_url
# Store the server process in the global dictionary with the correct workspace
_RUNNING_SERVERS[self.sid] = ActionExecutionServerInfo(
process=self.server_process,
execution_server_port=self._execution_server_port,
vscode_port=self._vscode_port,
app_ports=self._app_ports,
log_thread=self._log_thread,
log_thread_exit_event=self._log_thread_exit_event,
temp_workspace=self._temp_workspace,
workspace_mount_path=self.config.workspace_mount_path_in_sandbox,
)
self.log('info', f'Waiting for server to become ready at {self.api_url}...')
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
await call_sync_from_async(self._wait_until_alive)
if not self.attach_to_existing:
await call_sync_from_async(self.setup_initial_env)
self.log(
'debug',
f'Server initialized with plugins: {[plugin.name for plugin in self.plugins]}',
)
if not self.attach_to_existing:
self.set_runtime_status(RuntimeStatus.READY)
self._runtime_initialized = True
# Check if we need to create more warm servers after connecting
if (
desired_num_warm_servers > 0
and len(_WARM_SERVERS) < desired_num_warm_servers
):
num_to_create = desired_num_warm_servers - len(_WARM_SERVERS)
self.log(
'info',
f'Creating {num_to_create} additional warm servers to reach desired count',
)
for _ in range(num_to_create):
_create_warm_server_in_background(self.config, self.plugins)
@classmethod
def setup(cls, config: OpenHandsConfig, headless_mode: bool = False):
should_check_dependencies = os.getenv('SKIP_DEPENDENCY_CHECK', '') != '1'
if should_check_dependencies:
code_repo_path = os.path.dirname(os.path.dirname(openhands.__file__))
check_browser = config.enable_browser and sys.platform != 'win32'
check_dependencies(code_repo_path, check_browser)
initial_num_warm_servers = int(os.getenv('INITIAL_NUM_WARM_SERVERS', '0'))
# Initialize warm servers if needed
if initial_num_warm_servers > 0 and len(_WARM_SERVERS) == 0:
plugins: list[PluginRequirement] = []
# Copy the logic from Runtime where we add a VSCodePlugin on init if missing
if not headless_mode and not DISABLE_VSCODE_PLUGIN:
plugins.append(VSCodeRequirement())
for _ in range(initial_num_warm_servers):
_create_warm_server(config, plugins)
@tenacity.retry(
wait=tenacity.wait_fixed(2),
stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
before_sleep=lambda retry_state: logger.debug(
f'Waiting for server to be ready... (attempt {retry_state.attempt_number})'
),
)
def _wait_until_alive(self) -> bool:
"""Wait until the server is ready to accept requests."""
if self.server_process and self.server_process.poll() is not None:
raise RuntimeError('Server process died')
try:
response = self.session.get(f'{self.api_url}/alive')
response.raise_for_status()
return True
except Exception as e:
self.log('debug', f'Server not ready yet: {e}')
raise
async def execute_action(self, action: Action) -> Observation:
"""Execute an action by sending it to the server."""
if not self.runtime_initialized:
raise AgentRuntimeDisconnectedError('Runtime not initialized')
# Check if our server process is still valid
if self.server_process is None:
# Check if there's a server in the global dictionary
if self.sid in _RUNNING_SERVERS:
self.server_process = _RUNNING_SERVERS[self.sid].process
else:
raise AgentRuntimeDisconnectedError('Server process not found')
# Check if the server process is still running
if self.server_process.poll() is not None:
# If the process died, remove it from the global dictionary
if self.sid in _RUNNING_SERVERS:
del _RUNNING_SERVERS[self.sid]
raise AgentRuntimeDisconnectedError('Server process died')
with self.action_semaphore:
try:
response = await call_sync_from_async(
lambda: self.session.post(
f'{self.api_url}/execute_action',
json={'action': event_to_dict(action)},
)
)
# After executing the action, check if we need to create more warm servers
desired_num_warm_servers = int(
os.getenv('DESIRED_NUM_WARM_SERVERS', '0')
)
if (
desired_num_warm_servers > 0
and len(_WARM_SERVERS) < desired_num_warm_servers
):
self.log(
'info',
f'Creating a new warm server to maintain desired count of {desired_num_warm_servers}',
)
_create_warm_server_in_background(self.config, self.plugins)
return observation_from_dict(response.json())
except httpx.NetworkError:
raise AgentRuntimeDisconnectedError('Server connection lost')
def close(self) -> None:
"""Stop the server process if not in attach_to_existing mode."""
# If we're in attach_to_existing mode, don't close the server
if self.attach_to_existing:
self.log(
'info',
f'Not closing server for session {self.sid} (attach_to_existing=True)',
)
# Just clean up our reference to the process, but leave it running
self.server_process = None
# Don't clean up temp workspace when attach_to_existing=True
super().close()
return
# Signal the log thread to exit
self._log_thread_exit_event.set()
# Remove from global dictionary
if self.sid in _RUNNING_SERVERS:
del _RUNNING_SERVERS[self.sid]
if self.server_process:
self.server_process.terminate()
try:
self.server_process.wait(timeout=5)
except subprocess.TimeoutExpired:
self.server_process.kill()
self.server_process = None
self._log_thread.join(timeout=5) # Add timeout to join
# Clean up temp workspace if it exists and we created it
if self._temp_workspace and not self.attach_to_existing:
shutil.rmtree(self._temp_workspace)
self._temp_workspace = None
super().close()
@classmethod
async def delete(cls, conversation_id: str) -> None:
"""Delete the runtime for a conversation."""
if conversation_id in _RUNNING_SERVERS:
logger.info(f'Deleting LocalRuntime for conversation {conversation_id}')
server_info = _RUNNING_SERVERS[conversation_id]
# Signal the log thread to exit
server_info.log_thread_exit_event.set()
# Terminate the server process
if server_info.process:
server_info.process.terminate()
try:
server_info.process.wait(timeout=5)
except subprocess.TimeoutExpired:
server_info.process.kill()
# Wait for the log thread to finish
server_info.log_thread.join(timeout=5)
# Remove from global dictionary
del _RUNNING_SERVERS[conversation_id]
logger.info(f'LocalRuntime for conversation {conversation_id} deleted')
# Also clean up any warm servers if this is the last conversation being deleted
if not _RUNNING_SERVERS:
logger.info('No active conversations, cleaning up warm servers')
for server_info in _WARM_SERVERS[:]:
# Signal the log thread to exit
server_info.log_thread_exit_event.set()
# Terminate the server process
if server_info.process:
server_info.process.terminate()
try:
server_info.process.wait(timeout=5)
except subprocess.TimeoutExpired:
server_info.process.kill()
# Wait for the log thread to finish
server_info.log_thread.join(timeout=5)
# Clean up temp workspace
if server_info.temp_workspace:
shutil.rmtree(server_info.temp_workspace)
# Remove from warm servers list
_WARM_SERVERS.remove(server_info)
logger.info('All warm servers cleaned up')
@property
def runtime_url(self) -> str:
runtime_url = os.getenv('RUNTIME_URL')
if runtime_url:
return runtime_url
# TODO: This could be removed if we had a straightforward variable containing the RUNTIME_URL in the K8 env.
runtime_url_pattern = os.getenv('RUNTIME_URL_PATTERN')
runtime_id = os.getenv('RUNTIME_ID')
if runtime_url_pattern and runtime_id:
runtime_url = runtime_url_pattern.format(runtime_id=runtime_id)
return runtime_url
# Fallback to localhost
return self.config.sandbox.local_runtime_url
def _create_url(self, prefix: str, port: int) -> str:
runtime_url = self.runtime_url
logger.debug(f'runtime_url is {runtime_url}')
if 'localhost' in runtime_url:
url = f'{self.runtime_url}:{self._vscode_port}'
else:
runtime_id = os.getenv('RUNTIME_ID')
parsed = urlparse(self.runtime_url)
scheme, netloc, path = parsed.scheme, parsed.netloc, parsed.path or '/'
path_mode = path.startswith(f'/{runtime_id}') if runtime_id else False
if path_mode:
url = f'{scheme}://{netloc}/{runtime_id}/{prefix}'
else:
url = f'{scheme}://{prefix}-{netloc}'
logger.debug(f'_create_url url is {url}')
return url
@property
def vscode_url(self) -> str | None:
token = super().get_vscode_token()
if not token:
return None
vscode_url = self._create_url('vscode', self._vscode_port)
return f'{vscode_url}/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
@property
def web_hosts(self) -> dict[str, int]:
hosts: dict[str, int] = {}
for index, port in enumerate(self._app_ports):
url = self._create_url(f'work-{index + 1}', port)
hosts[url] = port
return hosts
def _python_bin_path():
# Derive environment paths using sys.executable
interpreter_path = sys.executable
python_bin_path = os.path.dirname(interpreter_path)
return python_bin_path
def _create_server(
config: OpenHandsConfig,
plugins: list[PluginRequirement],
workspace_prefix: str,
) -> tuple[ActionExecutionServerInfo, str]:
logger.info('Creating a server')
# Set up workspace directory
temp_workspace = tempfile.mkdtemp(
prefix=f'openhands_workspace_{workspace_prefix}',
)
workspace_mount_path = temp_workspace
# Find available ports
execution_server_port = find_available_tcp_port(*EXECUTION_SERVER_PORT_RANGE)
vscode_port = int(
os.getenv('VSCODE_PORT') or str(find_available_tcp_port(*VSCODE_PORT_RANGE))
)
app_ports = [
int(
os.getenv('WORK_PORT_1')
or os.getenv('APP_PORT_1')
or str(find_available_tcp_port(*APP_PORT_RANGE_1))
),
int(
os.getenv('WORK_PORT_2')
or os.getenv('APP_PORT_2')
or str(find_available_tcp_port(*APP_PORT_RANGE_2))
),
]
# Get user info
user_id, username = get_user_info()
# Start the server process
cmd = get_action_execution_server_startup_command(
server_port=execution_server_port,
plugins=plugins,
app_config=config,
python_prefix=[],
python_executable=sys.executable,
override_user_id=user_id,
override_username=username,
)
logger.info(f'Starting server with command: {cmd}')
env = os.environ.copy()
# Get the code repo path
code_repo_path = os.path.dirname(os.path.dirname(openhands.__file__))
env['PYTHONPATH'] = os.pathsep.join([code_repo_path, env.get('PYTHONPATH', '')])
env['OPENHANDS_REPO_PATH'] = code_repo_path
env['LOCAL_RUNTIME_MODE'] = '1'
env['VSCODE_PORT'] = str(vscode_port)
# Prepend the interpreter's bin directory to PATH for subprocesses
env['PATH'] = f'{_python_bin_path()}{os.pathsep}{env.get("PATH", "")}'
logger.debug(f'Updated PATH for subprocesses: {env["PATH"]}')
server_process = subprocess.Popen( # noqa: S603
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
bufsize=1,
env=env,
cwd=code_repo_path,
)
log_thread_exit_event = threading.Event()
# Start a thread to read and log server output
def log_output() -> None:
if not server_process or not server_process.stdout:
logger.error('server process or stdout not available for logging.')
return
try:
# Read lines while the process is running and stdout is available
while server_process.poll() is None:
if log_thread_exit_event.is_set():
logger.info('server log thread received exit signal.')
break
line = server_process.stdout.readline()
if not line:
break
logger.info(f'server: {line.strip()}')
# Capture any remaining output
if not log_thread_exit_event.is_set():
logger.info('server process exited, reading remaining output.')
for line in server_process.stdout:
if log_thread_exit_event.is_set():
break
logger.info(f'server (remaining): {line.strip()}')
except Exception as e:
logger.error(f'Error reading server output: {e}')
finally:
logger.info('server log output thread finished.')
log_thread = threading.Thread(target=log_output, daemon=True)
log_thread.start()
# Create server info object
server_info = ActionExecutionServerInfo(
process=server_process,
execution_server_port=execution_server_port,
vscode_port=vscode_port,
app_ports=app_ports,
log_thread=log_thread,
log_thread_exit_event=log_thread_exit_event,
temp_workspace=temp_workspace,
workspace_mount_path=workspace_mount_path,
)
# API URL for the server
api_url = f'{config.sandbox.local_runtime_url}:{execution_server_port}'
return server_info, api_url
def _create_warm_server(
config: OpenHandsConfig,
plugins: list[PluginRequirement],
) -> None:
"""Create a warm server in the background."""
try:
server_info, api_url = _create_server(
config=config,
plugins=plugins,
workspace_prefix='warm',
)
# Wait for the server to be ready
session = httpx.Client(timeout=30, verify=httpx_verify_option())
# Use tenacity to retry the connection
@tenacity.retry(
wait=tenacity.wait_fixed(2),
stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
before_sleep=lambda retry_state: logger.debug(
f'Waiting for warm server to be ready... (attempt {retry_state.attempt_number})'
),
)
def wait_until_alive() -> bool:
if server_info.process.poll() is not None:
raise RuntimeError('Warm server process died')
try:
response = session.get(f'{api_url}/alive')
response.raise_for_status()
return True
except Exception as e:
logger.debug(f'Warm server not ready yet: {e}')
raise
wait_until_alive()
logger.info(f'Warm server ready at port {server_info.execution_server_port}')
# Add to the warm servers list
_WARM_SERVERS.append(server_info)
except Exception as e:
logger.error(f'Failed to create warm server: {e}')
# Clean up resources
if 'server_info' in locals():
server_info.log_thread_exit_event.set()
if server_info.process:
server_info.process.terminate()
try:
server_info.process.wait(timeout=5)
except subprocess.TimeoutExpired:
server_info.process.kill()
server_info.log_thread.join(timeout=5)
if server_info.temp_workspace:
shutil.rmtree(server_info.temp_workspace)
def _create_warm_server_in_background(
config: OpenHandsConfig,
plugins: list[PluginRequirement],
) -> None:
"""Start a new thread to create a warm server."""
thread = threading.Thread(
target=_create_warm_server, daemon=True, args=(config, plugins)
)
thread.start()
@@ -0,0 +1,640 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
import json
import logging
import os
from typing import Any, Callable
from urllib.parse import urlparse
import httpx
import tenacity
from tenacity import RetryCallState
from openhands.core.config import OpenHandsConfig
from openhands.core.exceptions import (
AgentRuntimeDisconnectedError,
AgentRuntimeError,
AgentRuntimeNotFoundError,
AgentRuntimeNotReadyError,
AgentRuntimeUnavailableError,
)
from openhands.core.logger import openhands_logger as logger
from openhands.events import EventStream
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.llm.llm_registry import LLMRegistry
from openhands.runtime.builder.remote import RemoteRuntimeBuilder
from openhands.runtime.impl.action_execution.action_execution_client import (
ActionExecutionClient,
)
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.runtime_status import RuntimeStatus
from openhands.runtime.utils.command import (
DEFAULT_MAIN_MODULE,
get_action_execution_server_startup_command,
)
from openhands.runtime.utils.request import send_request
from openhands.runtime.utils.runtime_build import build_runtime_image
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.tenacity_stop import stop_base, stop_if_should_exit
class _StopIfClosed(stop_base):
def __init__(self, runtime: ActionExecutionClient):
self.runtime = runtime
def __call__(self, retry_state):
return self.runtime._runtime_closed
class RemoteRuntime(ActionExecutionClient):
"""This runtime will connect to a remote oh-runtime-client."""
port: int = 60000 # default port for the remote runtime client
runtime_id: str | None = None
runtime_url: str | None = None
_runtime_initialized: bool = False
runtime_builder: RemoteRuntimeBuilder
container_image: str
available_hosts: dict[str, int]
main_module: str
def __init__(
self,
config: OpenHandsConfig,
event_stream: EventStream,
llm_registry: LLMRegistry,
sid: str = 'default',
plugins: list[PluginRequirement] | None = None,
env_vars: dict[str, str] | None = None,
status_callback: Callable[..., None] | None = None,
attach_to_existing: bool = False,
headless_mode: bool = True,
user_id: str | None = None,
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
main_module: str = DEFAULT_MAIN_MODULE,
) -> None:
super().__init__(
config,
event_stream,
llm_registry,
sid,
plugins,
env_vars,
status_callback,
attach_to_existing,
headless_mode,
user_id,
git_provider_tokens,
)
logger.debug(f'RemoteRuntime.init user_id {user_id}')
if self.config.sandbox.api_key is None:
raise ValueError(
'API key is required to use the remote runtime. '
'Please set the API key in the config (config.toml) or as an environment variable (SANDBOX_API_KEY).'
)
self.session.headers.update({'X-API-Key': self.config.sandbox.api_key})
if self.config.workspace_base is not None:
self.log(
'debug',
'Setting workspace_base is not supported in the remote runtime.',
)
if self.config.sandbox.remote_runtime_api_url is None:
raise ValueError(
'remote_runtime_api_url is required in the remote runtime.'
)
assert self.config.sandbox.remote_runtime_class in (None, 'sysbox', 'gvisor')
self.main_module = main_module
self.runtime_builder = RemoteRuntimeBuilder(
self.config.sandbox.remote_runtime_api_url,
self.config.sandbox.api_key,
self.session,
)
self.available_hosts: dict[str, int] = {}
self._session_api_key: str | None = None
def log(self, level: str, message: str, exc_info: bool | None = None) -> None:
getattr(logger, level)(
message,
stacklevel=2,
exc_info=exc_info,
extra={
'session_id': self.sid,
'runtime_id': self.runtime_id,
},
)
@property
def action_execution_server_url(self) -> str:
if self.runtime_url is None:
raise NotImplementedError('Runtime URL is not initialized')
return self.runtime_url
async def connect(self) -> None:
try:
await call_sync_from_async(self._start_or_attach_to_runtime)
except Exception:
self.close()
self.log('error', 'Runtime failed to start', exc_info=True)
raise
await call_sync_from_async(self.setup_initial_env)
self._runtime_initialized = True
def _start_or_attach_to_runtime(self) -> None:
self.log('info', 'Starting or attaching to runtime')
existing_runtime = self._check_existing_runtime()
if existing_runtime:
self.log('info', f'Using existing runtime with ID: {self.runtime_id}')
elif self.attach_to_existing:
self.log('info', f'Failed to find existing runtime for SID: {self.sid}')
raise AgentRuntimeNotFoundError(
f'Could not find existing runtime for SID: {self.sid}'
)
else:
self.log('info', 'No existing runtime found, starting a new one')
if self.config.sandbox.runtime_container_image is None:
self.log(
'info',
f'Building remote runtime with base image: {self.config.sandbox.base_container_image}',
)
self._build_runtime()
else:
self.log(
'info',
f'Starting remote runtime with image: {self.config.sandbox.runtime_container_image}',
)
self.container_image = self.config.sandbox.runtime_container_image
self._start_runtime()
assert self.runtime_id is not None, (
'Runtime ID is not set. This should never happen.'
)
assert self.runtime_url is not None, (
'Runtime URL is not set. This should never happen.'
)
if not self.attach_to_existing:
self.log('info', 'Waiting for runtime to be alive...')
self._wait_until_alive()
if not self.attach_to_existing:
self.log('info', 'Runtime is ready.')
self.set_runtime_status(RuntimeStatus.READY)
def _check_existing_runtime(self) -> bool:
self.log('info', f'Checking for existing runtime with session ID: {self.sid}')
try:
response = self._send_runtime_api_request(
'GET',
f'{self.config.sandbox.remote_runtime_api_url}/sessions/{self.sid}',
)
data = response.json()
status = data.get('status')
self.log('info', f'Found runtime with status: {status}')
if status == 'running' or status == 'paused':
self._parse_runtime_response(response)
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
self.log(
'info', f'No existing runtime found for session ID: {self.sid}'
)
return False
self.log(
'error', f'Error while looking for remote runtime: {e}', exc_info=True
)
raise
except httpx.HTTPError as e:
self.log(
'error', f'Error while looking for remote runtime: {e}', exc_info=True
)
raise
except json.decoder.JSONDecodeError as e:
self.log(
'error',
f'Invalid JSON response from runtime API: {e}. URL: {self.config.sandbox.remote_runtime_api_url}/sessions/{self.sid}. Response: {response}',
exc_info=True,
)
raise
if status == 'running':
self.log('info', 'Found existing runtime in running state')
return True
elif status == 'stopped':
self.log('info', 'Found existing runtime, but it is stopped')
return False
elif status == 'paused':
self.log(
'info', 'Found existing runtime in paused state, attempting to resume'
)
try:
self._resume_runtime()
self.log('info', 'Successfully resumed paused runtime')
return True
except Exception as e:
self.log(
'error', f'Failed to resume paused runtime: {e}', exc_info=True
)
# Return false to indicate we couldn't use the existing runtime
return False
else:
self.log('error', f'Invalid response from runtime API: {data}')
return False
def _build_runtime(self) -> None:
self.log('debug', f'Building RemoteRuntime config:\n{self.config}')
self.set_runtime_status(RuntimeStatus.BUILDING_RUNTIME)
response = self._send_runtime_api_request(
'GET',
f'{self.config.sandbox.remote_runtime_api_url}/registry_prefix',
)
response_json = response.json()
registry_prefix = response_json['registry_prefix']
os.environ['OH_RUNTIME_RUNTIME_IMAGE_REPO'] = (
registry_prefix.rstrip('/') + '/runtime'
)
self.log(
'debug',
f'Runtime image repo: {os.environ["OH_RUNTIME_RUNTIME_IMAGE_REPO"]}',
)
if self.config.sandbox.base_container_image is None:
raise ValueError(
'base_container_image is required to build the runtime image. '
)
if self.config.sandbox.runtime_extra_deps:
self.log(
'debug',
f'Installing extra user-provided dependencies in the runtime image: {self.config.sandbox.runtime_extra_deps}',
)
# Build the container image
self.container_image = build_runtime_image(
self.config.sandbox.base_container_image,
self.runtime_builder,
platform=self.config.sandbox.platform,
extra_deps=self.config.sandbox.runtime_extra_deps,
force_rebuild=self.config.sandbox.force_rebuild_runtime,
enable_browser=self.config.enable_browser,
)
response = self._send_runtime_api_request(
'GET',
f'{self.config.sandbox.remote_runtime_api_url}/image_exists',
params={'image': self.container_image},
)
if not response.json()['exists']:
raise AgentRuntimeError(
f'Container image {self.container_image} does not exist'
)
def _start_runtime(self) -> None:
# Prepare the request body for the /start endpoint
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
command = self.get_action_execution_server_startup_command()
environment: dict[str, str] = {}
if self.config.debug or os.environ.get('DEBUG', 'false').lower() == 'true':
environment['DEBUG'] = 'true'
environment.update(self.config.sandbox.runtime_startup_env_vars)
start_request: dict[str, Any] = {
'image': self.container_image,
'command': command,
'working_dir': '/openhands/code/',
'environment': environment,
'session_id': self.sid,
'resource_factor': self.config.sandbox.remote_runtime_resource_factor,
}
if self.config.sandbox.remote_runtime_class == 'sysbox':
start_request['runtime_class'] = 'sysbox-runc'
# We ignore other runtime classes for now, because both None and 'gvisor' map to 'gvisor'
# Start the sandbox using the /start endpoint
try:
response = self._send_runtime_api_request(
'POST',
f'{self.config.sandbox.remote_runtime_api_url}/start',
json=start_request,
)
self._parse_runtime_response(response)
self.log(
'debug',
f'Runtime started. URL: {self.runtime_url}',
)
except httpx.HTTPError as e:
self.log('error', f'Unable to start runtime: {str(e)}')
raise AgentRuntimeUnavailableError() from e
def _resume_runtime(self) -> None:
"""Resume a stopped runtime.
Steps:
1. Show status update that runtime is being started.
2. Send the runtime API a /resume request
3. Poll for the runtime to be ready
4. Update env vars
"""
self.log('info', f'Attempting to resume runtime with ID: {self.runtime_id}')
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
try:
response = self._send_runtime_api_request(
'POST',
f'{self.config.sandbox.remote_runtime_api_url}/resume',
json={'runtime_id': self.runtime_id},
)
self.log(
'info',
f'Resume API call successful with status code: {response.status_code}',
)
except Exception as e:
self.log('error', f'Failed to call /resume API: {e}', exc_info=True)
raise
self.log(
'info', 'Runtime resume API call completed, waiting for it to be alive...'
)
try:
self._wait_until_alive()
self.log('info', 'Runtime is now alive after resume')
except Exception as e:
self.log(
'error',
f'Runtime failed to become alive after resume: {e}',
exc_info=True,
)
raise
try:
self.setup_initial_env()
self.log('info', 'Successfully set up initial environment after resume')
except Exception as e:
self.log(
'error',
f'Failed to set up initial environment after resume: {e}',
exc_info=True,
)
raise
self.log('info', 'Runtime successfully resumed and alive.')
def _parse_runtime_response(self, response: httpx.Response) -> None:
start_response = response.json()
self.runtime_id = start_response['runtime_id']
self.runtime_url = start_response['url']
self.available_hosts = start_response.get('work_hosts', {})
if 'session_api_key' in start_response:
self.session.headers.update(
{'X-Session-API-Key': start_response['session_api_key']}
)
self._session_api_key = start_response['session_api_key']
self.log(
'debug',
'Session API key set',
)
@property
def session_api_key(self) -> str | None:
return self._session_api_key
@property
def vscode_url(self) -> str | None:
token = super().get_vscode_token()
if not token:
return None
assert self.runtime_url is not None and self.runtime_id is not None
self.log('debug', f'runtime_url: {self.runtime_url}')
parsed = urlparse(self.runtime_url)
scheme, netloc, path = parsed.scheme, parsed.netloc, parsed.path or '/'
# Path mode if runtime_url path starts with /{id}
path_mode = path.startswith(f'/{self.runtime_id}')
if path_mode:
vscode_url = f'{scheme}://{netloc}/{self.runtime_id}/vscode?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
else:
vscode_url = f'{scheme}://vscode-{netloc}/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
self.log(
'debug',
f'VSCode URL: {vscode_url}',
)
return vscode_url
@property
def web_hosts(self) -> dict[str, int]:
return self.available_hosts
def _wait_until_alive(self) -> None:
retry_decorator = tenacity.retry(
stop=tenacity.stop_after_delay(
self.config.sandbox.remote_runtime_init_timeout
)
| stop_if_should_exit()
| _StopIfClosed(self),
reraise=True,
retry=tenacity.retry_if_exception_type(AgentRuntimeNotReadyError),
wait=tenacity.wait_fixed(2),
)
retry_decorator(self._wait_until_alive_impl)()
def _wait_until_alive_impl(self) -> None:
self.log('debug', f'Waiting for runtime to be alive at url: {self.runtime_url}')
self.log(
'debug',
f'Sending request to: {self.config.sandbox.remote_runtime_api_url}/runtime/{self.runtime_id}',
)
runtime_info_response = self._send_runtime_api_request(
'GET',
f'{self.config.sandbox.remote_runtime_api_url}/runtime/{self.runtime_id}',
)
runtime_data = runtime_info_response.json()
self.log(
'debug',
f'received response: {runtime_data}',
)
assert 'runtime_id' in runtime_data
assert runtime_data['runtime_id'] == self.runtime_id
assert 'pod_status' in runtime_data
pod_status = runtime_data['pod_status'].lower()
self.log('debug', f'Pod status: {pod_status}')
restart_count = runtime_data.get('restart_count', 0)
if restart_count != 0:
restart_reasons = runtime_data.get('restart_reasons')
self.log(
'debug', f'Pod restarts: {restart_count}, reasons: {restart_reasons}'
)
# FIXME: We should fix it at the backend of /start endpoint, make sure
# the pod is created before returning the response.
# Retry a period of time to give the cluster time to start the pod
if pod_status == 'ready':
try:
self.check_if_alive()
except httpx.HTTPError as e:
self.log(
'warning',
f"Runtime /alive failed, but pod says it's ready: {str(e)}",
)
raise AgentRuntimeNotReadyError(
f'Runtime /alive failed to respond with 200: {str(e)}'
)
return
elif (
pod_status == 'not found'
or pod_status == 'pending'
or pod_status == 'running'
): # nb: Running is not yet Ready
raise AgentRuntimeNotReadyError(
f'Runtime (ID={self.runtime_id}) is not yet ready. Status: {pod_status}'
)
elif pod_status in ('failed', 'unknown', 'crashloopbackoff'):
if pod_status == 'crashloopbackoff':
raise AgentRuntimeUnavailableError(
'Runtime crashed and is being restarted, potentially due to memory usage. Please try again.'
)
else:
raise AgentRuntimeUnavailableError(
f'Runtime is unavailable (status: {pod_status}). Please try again.'
)
else:
# Maybe this should be a hard failure, but passing through in case the API changes
self.log('warning', f'Unknown pod status: {pod_status}')
self.log(
'debug',
f'Waiting for runtime pod to be active. Current status: {pod_status}',
)
raise AgentRuntimeNotReadyError()
def close(self) -> None:
if self.attach_to_existing:
super().close()
return
if self.config.sandbox.keep_runtime_alive:
if self.config.sandbox.pause_closed_runtimes:
try:
if not self._runtime_closed:
self._send_runtime_api_request(
'POST',
f'{self.config.sandbox.remote_runtime_api_url}/pause',
json={'runtime_id': self.runtime_id},
)
self.log('info', 'Runtime paused.')
except Exception as e:
self.log('error', f'Unable to pause runtime: {str(e)}')
raise e
super().close()
return
try:
if not self._runtime_closed:
self._send_runtime_api_request(
'POST',
f'{self.config.sandbox.remote_runtime_api_url}/stop',
json={'runtime_id': self.runtime_id},
)
self.log('info', 'Runtime stopped.')
except Exception as e:
self.log('error', f'Unable to stop runtime: {str(e)}')
raise e
finally:
super().close()
def _send_runtime_api_request(
self, method: str, url: str, **kwargs: Any
) -> httpx.Response:
try:
kwargs['timeout'] = self.config.sandbox.remote_runtime_api_timeout
return send_request(self.session, method, url, **kwargs)
except httpx.TimeoutException:
self.log(
'error',
f'No response received within the timeout period for url: {url}',
)
raise
def _send_action_server_request(
self, method: str, url: str, **kwargs: Any
) -> httpx.Response:
if not self.config.sandbox.remote_runtime_enable_retries:
return self._send_action_server_request_impl(method, url, **kwargs)
retry_decorator = tenacity.retry(
retry=tenacity.retry_if_exception_type(httpx.NetworkError),
stop=tenacity.stop_after_attempt(3)
| stop_if_should_exit()
| _StopIfClosed(self),
before_sleep=tenacity.before_sleep_log(
logger, # type: ignore[arg-type]
logging.WARNING,
),
wait=tenacity.wait_exponential(multiplier=1, min=4, max=60),
)
return retry_decorator(self._send_action_server_request_impl)(
method, url, **kwargs
)
def _send_action_server_request_impl(
self, method: str, url: str, **kwargs: Any
) -> httpx.Response:
try:
return super()._send_action_server_request(method, url, **kwargs)
except httpx.TimeoutException:
self.log(
'error',
f'No response received within the timeout period for url: {url}',
)
raise
except httpx.HTTPError as e:
if hasattr(e, 'response') and e.response.status_code in (404, 502, 504):
if e.response.status_code == 404:
raise AgentRuntimeDisconnectedError(
f'Runtime is not responding. This may be temporary, please try again. Original error: {e}'
) from e
else: # 502, 504
raise AgentRuntimeDisconnectedError(
f'Runtime is temporarily unavailable. This may be due to a restart or network issue, please try again. Original error: {e}'
) from e
elif hasattr(e, 'response') and e.response.status_code == 503:
if self.config.sandbox.keep_runtime_alive:
self.log(
'info',
f'Runtime appears to be paused (503 response). Runtime ID: {self.runtime_id}, URL: {url}',
)
try:
self._resume_runtime()
self.log(
'info', 'Successfully resumed runtime after 503 response'
)
return super()._send_action_server_request(
method, url, **kwargs
)
except Exception as resume_error:
self.log(
'error',
f'Failed to resume runtime after 503 response: {resume_error}',
exc_info=True,
)
raise AgentRuntimeDisconnectedError(
f'Runtime is paused and could not be resumed. Original error: {e}, Resume error: {resume_error}'
) from resume_error
else:
self.log(
'info',
'Runtime appears to be paused (503 response) but keep_runtime_alive is False',
)
raise AgentRuntimeDisconnectedError(
f'Runtime is temporarily unavailable. This may be due to a restart or network issue, please try again. Original error: {e}'
) from e
else:
raise e
def _stop_if_closed(self, retry_state: RetryCallState) -> bool:
return self._runtime_closed
def get_action_execution_server_startup_command(self):
return get_action_execution_server_startup_command(
server_port=self.port,
plugins=self.plugins,
app_config=self.config,
main_module=self.main_module,
)
+6
View File
@@ -0,0 +1,6 @@
{
"mcpServers": {
"default": {}
},
"tools": []
}
+71
View File
@@ -0,0 +1,71 @@
# MCP Proxy Manager
This module provides a manager class for handling FastMCP proxy instances in OpenHands, including initialization, configuration, and mounting to FastAPI applications.
## Overview
The `MCPProxyManager` class encapsulates all the functionality related to creating, configuring, and managing FastMCP proxy instances. It simplifies the process of:
1. Initializing a FastMCP proxy
2. Configuring the proxy with tools
3. Mounting the proxy to a FastAPI application
4. Updating the proxy configuration
5. Shutting down the proxy
## Usage
### Basic Usage
```python
from openhands.runtime.mcp.proxy import MCPProxyManager
from fastapi import FastAPI
# Create a FastAPI app
app = FastAPI()
# Create a proxy manager
proxy_manager = MCPProxyManager(
name="MyProxyServer",
auth_enabled=True,
api_key="my-api-key"
)
# Initialize the proxy
proxy_manager.initialize()
# Mount the proxy to the app
await proxy_manager.mount_to_app(app, allow_origins=["*"])
# Update the tools configuration
tools = [
{
"name": "my_tool",
"description": "My tool description",
"parameters": {...}
}
]
proxy_manager.update_tools(tools)
# Update and remount the proxy
await proxy_manager.update_and_remount(app, tools, allow_origins=["*"])
# Shutdown the proxy
await proxy_manager.shutdown()
```
### In-Memory Configuration
The `MCPProxyManager` maintains the configuration in-memory, eliminating the need for file-based configuration. This makes it easier to update the configuration and reduces the complexity of the code.
## Benefits
1. **Simplified API**: The `MCPProxyManager` provides a simple and intuitive API for managing FastMCP proxies.
2. **In-Memory Configuration**: Configuration is maintained in-memory, eliminating the need for file I/O operations.
3. **Improved Error Handling**: The manager provides better error handling and logging for proxy operations.
4. **Cleaner Code**: By encapsulating proxy-related functionality in a dedicated class, the code is more maintainable and easier to understand.
## Implementation Details
The `MCPProxyManager` uses the `FastMCP.as_proxy()` method to create a proxy server. It manages the lifecycle of the proxy, including initialization, configuration updates, and shutdown.
When updating the tools configuration, the manager creates a new proxy with the updated configuration and remounts it to the FastAPI application, ensuring that the proxy is always up-to-date with the latest configuration.
+12
View File
@@ -0,0 +1,12 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
"""MCP Proxy module for OpenHands."""
from openhands.runtime.mcp.proxy.manager import MCPProxyManager
__all__ = ['MCPProxyManager']
+164
View File
@@ -0,0 +1,164 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
"""MCP Proxy Manager for OpenHands.
This module provides a manager class for handling FastMCP proxy instances,
including initialization, configuration, and mounting to FastAPI applications.
"""
import logging
from typing import Any, Optional
from anyio import get_cancelled_exc_class
from fastapi import FastAPI
from fastmcp import FastMCP
from fastmcp.server.auth import StaticTokenVerifier
from fastmcp.utilities.logging import get_logger as fastmcp_get_logger
from openhands.core.config.mcp_config import StdioMCPServer
logger = logging.getLogger(__name__)
fastmcp_logger = fastmcp_get_logger('fastmcp')
class MCPProxyManager:
"""Manager for FastMCP proxy instances.
This class encapsulates all the functionality related to creating, configuring,
and managing FastMCP proxy instances, including mounting them to FastAPI applications.
"""
def __init__(
self,
auth_enabled: bool = False,
api_key: Optional[str] = None,
logger_level: Optional[int] = None,
):
"""Initialize the MCP Proxy Manager.
Args:
name: Name of the proxy server
auth_enabled: Whether authentication is enabled
api_key: API key for authentication (required if auth_enabled is True)
logger_level: Logging level for the FastMCP logger
"""
self.auth_enabled = auth_enabled
self.api_key = api_key
self.proxy: Optional[FastMCP] = None
# Initialize with a valid configuration format for FastMCP
self.config: dict[str, Any] = {
'mcpServers': {},
}
# Configure FastMCP logger
if logger_level is not None:
fastmcp_logger.setLevel(logger_level)
def initialize(self) -> None:
"""Initialize the FastMCP proxy with the current configuration."""
if len(self.config['mcpServers']) == 0:
logger.info(
'No MCP servers configured for FastMCP Proxy, skipping initialization.'
)
return None
# Create authentication provider if auth is enabled
auth_provider = None
if self.auth_enabled and self.api_key:
# Use StaticTokenVerifier for simple API key authentication
auth_provider = StaticTokenVerifier(
{self.api_key: {'client_id': 'openhands', 'scopes': []}}
)
logger.info('FastMCP Proxy authentication enabled')
else:
logger.info('FastMCP Proxy authentication disabled')
# Create a new proxy with the current configuration
self.proxy = FastMCP.as_proxy(
self.config,
auth=auth_provider,
)
logger.info('FastMCP Proxy initialized successfully')
async def mount_to_app(
self, app: FastAPI, allow_origins: Optional[list[str]] = None
) -> None:
"""Mount the SSE server app to a FastAPI application.
Args:
app: FastAPI application to mount to
allow_origins: List of allowed origins for CORS
"""
if len(self.config['mcpServers']) == 0:
logger.info('No MCP servers configured for FastMCP Proxy, skipping mount.')
return
if not self.proxy:
raise ValueError('FastMCP Proxy is not initialized')
def close_on_double_start(app):
async def wrapped(scope, receive, send):
start_sent = False
async def check_send(message):
nonlocal start_sent
if message['type'] == 'http.response.start':
if start_sent:
raise get_cancelled_exc_class()(
'closed because of double http.response.start (mcp issue https://github.com/modelcontextprotocol/python-sdk/issues/883)'
)
start_sent = True
await send(message)
await app(scope, receive, check_send)
return wrapped
# Get the SSE app
# mcp_app = self.proxy.http_app(path='/shttp')
mcp_app = close_on_double_start(
self.proxy.http_app(path='/sse', transport='sse')
)
app.mount('/mcp', mcp_app)
# Remove any existing mounts at root path
if '/mcp' in app.routes:
app.routes.remove('/mcp')
app.mount('/', mcp_app)
logger.info('Mounted FastMCP Proxy app at /mcp')
async def update_and_remount(
self,
app: FastAPI,
stdio_servers: list[StdioMCPServer],
allow_origins: Optional[list[str]] = None,
) -> None:
"""Update the tools configuration and remount the proxy to the app.
Args:
app: FastAPI application to mount to
stdio_servers: List of stdio server configurations (with ``name`` as extra field)
allow_origins: List of allowed origins for CORS
"""
tools = {}
for t in stdio_servers:
dump = t.model_dump()
name = dump.pop('name', None) or t.command
tools[name] = dump
self.config['mcpServers'] = tools
del self.proxy
self.proxy = None
# Initialize a new proxy
self.initialize()
# Mount the new proxy to the app
await self.mount_to_app(app, allow_origins)
+32
View File
@@ -0,0 +1,32 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
# Requirements
from openhands.runtime.plugins.agent_skills import (
AgentSkillsPlugin,
AgentSkillsRequirement,
)
from openhands.runtime.plugins.jupyter import JupyterPlugin, JupyterRequirement
from openhands.runtime.plugins.requirement import Plugin, PluginRequirement
from openhands.runtime.plugins.vscode import VSCodePlugin, VSCodeRequirement
__all__ = [
'Plugin',
'PluginRequirement',
'AgentSkillsRequirement',
'AgentSkillsPlugin',
'JupyterRequirement',
'JupyterPlugin',
'VSCodeRequirement',
'VSCodePlugin',
]
ALL_PLUGINS = {
'jupyter': JupyterPlugin,
'agent_skills': AgentSkillsPlugin,
'vscode': VSCodePlugin,
}
@@ -0,0 +1,57 @@
# OpenHands Skill Sets
This folder implements a skill/tool set `agentskills` for OpenHands.
It is intended to be used by the agent **inside sandbox**.
The skill set will be exposed as a `pip` package that can be installed as a plugin inside the sandbox.
The skill set can contain a bunch of wrapped tools for agent ([many examples here](https://github.com/OpenHands/OpenHands/pull/1914)), for example:
- Audio/Video to text (these are a temporary solution, and we should switch to multimodal models when they are sufficiently cheap
- PDF to text
- etc.
# Inclusion Criteria
We are walking a fine line here.
We DON't want to *wrap* every possible python packages and re-teach agent their usage (e.g., LLM already knows `pandas` pretty well, so we don't really need create a skill that reads `csv` - it can just use `pandas`).
We ONLY want to add a new skill, when:
- Such skill is not easily achievable for LLM to write code directly (e.g., edit code and replace certain line)
- It involves calling an external model (e.g., you need to call a speech to text model, editor model for speculative editing)
# Intended functionality
- Tool/skill usage (through `IPythonRunAction`)
```python
# In[1]
from agentskills import open_file, edit_file
open_file("/workspace/a.txt")
# Out[1]
[SWE-agent open output]
# In[2]
edit_file(
"/workspace/a.txt",
start=1, end=3,
content=(
("REPLACE TEXT")
))
# Out[1]
[SWE-agent edit output]
```
- Tool/skill retrieval (through `IPythonRunAction`)
```python
# In[1]
from agentskills import help_me
help_me("I want to solve a task that involves reading a bunch of PDFs and reason about them")
# Out[1]
"Here are the top skills that may be helpful to you:
- `pdf_to_text`: [documentation about the tools]
...
"
```
@@ -0,0 +1,31 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
from dataclasses import dataclass
from openhands.events.action import Action
from openhands.events.observation import Observation
from openhands.runtime.plugins.agent_skills import agentskills
from openhands.runtime.plugins.requirement import Plugin, PluginRequirement
@dataclass
class AgentSkillsRequirement(PluginRequirement):
name: str = 'agent_skills'
documentation: str = agentskills.DOCUMENTATION
class AgentSkillsPlugin(Plugin):
name: str = 'agent_skills'
async def initialize(self, username: str) -> None:
"""Initialize the plugin."""
pass
async def run(self, action: Action) -> Observation:
"""Run the plugin for a given action."""
raise NotImplementedError('AgentSkillsPlugin does not support run method')
@@ -0,0 +1,52 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
from inspect import signature
from openhands.runtime.plugins.agent_skills import file_ops, file_reader
from openhands.runtime.plugins.agent_skills.utils.dependency import import_functions
import_functions(
module=file_ops, function_names=file_ops.__all__, target_globals=globals()
)
import_functions(
module=file_reader, function_names=file_reader.__all__, target_globals=globals()
)
__all__ = file_ops.__all__ + file_reader.__all__
try:
from openhands.runtime.plugins.agent_skills import repo_ops
import_functions(
module=repo_ops, function_names=repo_ops.__all__, target_globals=globals()
)
__all__ += repo_ops.__all__
except ImportError:
# If repo_ops is not available, we just skip importing it.
pass
DOCUMENTATION = ''
for func_name in __all__:
func = globals()[func_name]
cur_doc = func.__doc__
# remove indentation from docstring and extra empty lines
cur_doc = '\n'.join(filter(None, map(lambda x: x.strip(), cur_doc.split('\n'))))
# now add a consistent 4 indentation
cur_doc = '\n'.join(map(lambda x: ' ' * 4 + x, cur_doc.split('\n')))
fn_signature = f'{func.__name__}' + str(signature(func))
DOCUMENTATION += f'{fn_signature}:\n{cur_doc}\n\n'
# Add file_editor (a function)
from openhands.runtime.plugins.agent_skills.file_editor import file_editor # noqa: E402
__all__ += ['file_editor']
@@ -0,0 +1,3 @@
# File Editor
This file editor is largely based on Anthorpic released [`str_replace_editor`](https://github.com/anthropics/anthropic-quickstarts/tree/main/computer-use-demo/computer_use_demo/tools/edit.py). The original code was released under [MIT license](https://github.com/anthropics/anthropic-quickstarts/blob/e373524f07594d48c3f9563248ea282a4c306c0c/LICENSE).
@@ -0,0 +1,15 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
"""This file imports a global singleton of the `EditTool` class as well as raw functions that expose
its __call__.
The implementation of the `EditTool` class can be found at: https://github.com/OpenHands/openhands-aci/.
"""
from openhands_aci.editor import file_editor
__all__ = ['file_editor']
@@ -0,0 +1,14 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
from openhands.runtime.plugins.agent_skills.file_ops import file_ops
from openhands.runtime.plugins.agent_skills.utils.dependency import import_functions
import_functions(
module=file_ops, function_names=file_ops.__all__, target_globals=globals()
)
__all__ = file_ops.__all__
@@ -0,0 +1,344 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
"""File operations module for OpenHands agent.
This module provides a collection of file manipulation skills that enable the OpenHands
agent to perform various file operations such as opening, searching, and navigating
through files and directories.
Functions:
- open_file(path: str, line_number: int | None = 1, context_lines: int = 100): Opens a file and optionally moves to a specific line.
- goto_line(line_number: int): Moves the window to show the specified line number.
- scroll_down(): Moves the window down by the number of lines specified in WINDOW.
- scroll_up(): Moves the window up by the number of lines specified in WINDOW.
- search_dir(search_term: str, dir_path: str = './'): Searches for a term in all files in the specified directory.
- search_file(search_term: str, file_path: str | None = None): Searches for a term in the specified file or the currently open file.
- find_file(file_name: str, dir_path: str = './'): Finds all files with the given name in the specified directory.
Note:
All functions return string representations of their results.
"""
import os
CURRENT_FILE: str | None = None
CURRENT_LINE = 1
WINDOW = 100
# This is also used in unit tests!
MSG_FILE_UPDATED = '[File updated (edited at line {line_number}). Please review the changes and make sure they are correct (correct indentation, no duplicate lines, etc). Edit the file again if necessary.]'
LINTER_ERROR_MSG = '[Your proposed edit has introduced new syntax error(s). Please understand the errors and retry your edit command.]\n'
# ==================================================================================================
def _output_error(error_msg: str) -> bool:
print(f'ERROR: {error_msg}')
return False
def _check_current_file(file_path: str | None = None) -> bool:
global CURRENT_FILE
if not file_path:
file_path = CURRENT_FILE
if not file_path or not os.path.isfile(file_path):
return _output_error('No file open. Use the open_file function first.')
return True
def _clamp(value: int, min_value: int, max_value: int) -> int:
return max(min_value, min(value, max_value))
def _print_window(
file_path: str | None,
targeted_line: int,
window: int,
return_str: bool = False,
ignore_window: bool = False,
) -> str:
global CURRENT_LINE
if not _check_current_file(file_path) or file_path is None:
return ''
with open(file_path) as file:
content = file.read()
# Ensure the content ends with a newline character
if not content.endswith('\n'):
content += '\n'
lines = content.splitlines(True) # Keep all line ending characters
total_lines = len(lines)
# cover edge cases
CURRENT_LINE = _clamp(targeted_line, 1, total_lines)
half_window = max(1, window // 2)
if ignore_window:
# Use CURRENT_LINE as starting line (for e.g. scroll_down)
start = max(1, CURRENT_LINE)
end = min(total_lines, CURRENT_LINE + window)
else:
# Ensure at least one line above and below the targeted line
start = max(1, CURRENT_LINE - half_window)
end = min(total_lines, CURRENT_LINE + half_window)
# Adjust start and end to ensure at least one line above and below
if start == 1:
end = min(total_lines, start + window - 1)
if end == total_lines:
start = max(1, end - window + 1)
output = ''
# only display this when there's at least one line above
if start > 1:
output += f'({start - 1} more lines above)\n'
else:
output += '(this is the beginning of the file)\n'
for i in range(start, end + 1):
_new_line = f'{i}|{lines[i - 1]}'
if not _new_line.endswith('\n'):
_new_line += '\n'
output += _new_line
if end < total_lines:
output += f'({total_lines - end} more lines below)\n'
else:
output += '(this is the end of the file)\n'
output = output.rstrip()
if return_str:
return output
else:
print(output)
return ''
def _cur_file_header(current_file: str | None, total_lines: int) -> str:
if not current_file:
return ''
return f'[File: {os.path.abspath(current_file)} ({total_lines} lines total)]\n'
def open_file(
path: str, line_number: int | None = 1, context_lines: int | None = WINDOW
) -> None:
"""Opens a file in the editor and optionally positions at a specific line.
The function displays a limited window of content, centered around the specified line
number if provided. To view the complete file content, the agent should use scroll_down and scroll_up
commands iteratively.
Args:
path: The path to the file to open. Absolute path is recommended.
line_number: The target line number to center the view on (if possible).
Defaults to 1.
context_lines: Maximum number of lines to display in the view window.
Limited to 100 lines. Defaults to 100.
"""
global CURRENT_FILE, CURRENT_LINE, WINDOW
if not os.path.isfile(path):
_output_error(f'File {path} not found.')
return
CURRENT_FILE = os.path.abspath(path)
with open(CURRENT_FILE) as file:
total_lines = max(1, sum(1 for _ in file))
if not isinstance(line_number, int) or line_number < 1 or line_number > total_lines:
_output_error(f'Line number must be between 1 and {total_lines}')
return
CURRENT_LINE = line_number
# Override WINDOW with context_lines
if context_lines is None or context_lines < 1:
context_lines = WINDOW
output = _cur_file_header(CURRENT_FILE, total_lines)
output += _print_window(
CURRENT_FILE,
CURRENT_LINE,
_clamp(context_lines, 1, 100),
return_str=True,
ignore_window=False,
)
if output.strip().endswith('more lines below)'):
output += '\n[Use `scroll_down` to view the next 100 lines of the file!]'
print(output)
def goto_line(line_number: int) -> None:
"""Moves the window to show the specified line number.
Args:
line_number: int: The line number to move to.
"""
global CURRENT_FILE, CURRENT_LINE, WINDOW
if not _check_current_file():
return
with open(str(CURRENT_FILE)) as file:
total_lines = max(1, sum(1 for _ in file))
if not isinstance(line_number, int) or line_number < 1 or line_number > total_lines:
_output_error(f'Line number must be between 1 and {total_lines}.')
return
CURRENT_LINE = _clamp(line_number, 1, total_lines)
output = _cur_file_header(CURRENT_FILE, total_lines)
output += _print_window(
CURRENT_FILE, CURRENT_LINE, WINDOW, return_str=True, ignore_window=False
)
print(output)
def scroll_down() -> None:
"""Moves the window down by 100 lines.
Args:
None
"""
global CURRENT_FILE, CURRENT_LINE, WINDOW
if not _check_current_file():
return
with open(str(CURRENT_FILE)) as file:
total_lines = max(1, sum(1 for _ in file))
CURRENT_LINE = _clamp(CURRENT_LINE + WINDOW, 1, total_lines)
output = _cur_file_header(CURRENT_FILE, total_lines)
output += _print_window(
CURRENT_FILE, CURRENT_LINE, WINDOW, return_str=True, ignore_window=True
)
print(output)
def scroll_up() -> None:
"""Moves the window up by 100 lines.
Args:
None
"""
global CURRENT_FILE, CURRENT_LINE, WINDOW
if not _check_current_file():
return
with open(str(CURRENT_FILE)) as file:
total_lines = max(1, sum(1 for _ in file))
CURRENT_LINE = _clamp(CURRENT_LINE - WINDOW, 1, total_lines)
output = _cur_file_header(CURRENT_FILE, total_lines)
output += _print_window(
CURRENT_FILE, CURRENT_LINE, WINDOW, return_str=True, ignore_window=True
)
print(output)
def search_dir(search_term: str, dir_path: str = './') -> None:
"""Searches for search_term in all files in dir. If dir is not provided, searches in the current directory.
Args:
search_term: str: The term to search for.
dir_path: str: The path to the directory to search.
"""
if not os.path.isdir(dir_path):
_output_error(f'Directory {dir_path} not found')
return
matches = []
for root, _, files in os.walk(dir_path):
for file in files:
if file.startswith('.'):
continue
file_path = os.path.join(root, file)
with open(file_path, 'r', errors='ignore') as f:
for line_num, line in enumerate(f, 1):
if search_term in line:
matches.append((file_path, line_num, line.strip()))
if not matches:
print(f'No matches found for "{search_term}" in {dir_path}')
return
num_matches = len(matches)
num_files = len(set(match[0] for match in matches))
if num_files > 100:
print(
f'More than {num_files} files matched for "{search_term}" in {dir_path}. Please narrow your search.'
)
return
print(f'[Found {num_matches} matches for "{search_term}" in {dir_path}]')
for file_path, line_num, line in matches:
print(f'{file_path} (Line {line_num}): {line}')
print(f'[End of matches for "{search_term}" in {dir_path}]')
def search_file(search_term: str, file_path: str | None = None) -> None:
"""Searches for search_term in file. If file is not provided, searches in the current open file.
Args:
search_term: The term to search for.
file_path: The path to the file to search.
"""
global CURRENT_FILE
if file_path is None:
file_path = CURRENT_FILE
if file_path is None:
_output_error('No file specified or open. Use the open_file function first.')
return
if not os.path.isfile(file_path):
_output_error(f'File {file_path} not found.')
return
matches = []
with open(file_path) as file:
for i, line in enumerate(file, 1):
if search_term in line:
matches.append((i, line.strip()))
if matches:
print(f'[Found {len(matches)} matches for "{search_term}" in {file_path}]')
for match in matches:
print(f'Line {match[0]}: {match[1]}')
print(f'[End of matches for "{search_term}" in {file_path}]')
else:
print(f'[No matches found for "{search_term}" in {file_path}]')
def find_file(file_name: str, dir_path: str = './') -> None:
"""Finds all files with the given name in the specified directory.
Args:
file_name: str: The name of the file to find.
dir_path: str: The path to the directory to search.
"""
if not os.path.isdir(dir_path):
_output_error(f'Directory {dir_path} not found')
return
matches = []
for root, _, files in os.walk(dir_path):
for file in files:
if file_name in file:
matches.append(os.path.join(root, file))
if matches:
print(f'[Found {len(matches)} matches for "{file_name}" in {dir_path}]')
for match in matches:
print(f'{match}')
print(f'[End of matches for "{file_name}" in {dir_path}]')
else:
print(f'[No matches found for "{file_name}" in {dir_path}]')
__all__ = [
'open_file',
'goto_line',
'scroll_down',
'scroll_up',
'search_dir',
'search_file',
'find_file',
]
@@ -0,0 +1,14 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
from openhands.runtime.plugins.agent_skills.file_reader import file_readers
from openhands.runtime.plugins.agent_skills.utils.dependency import import_functions
import_functions(
module=file_readers, function_names=file_readers.__all__, target_globals=globals()
)
__all__ = file_readers.__all__
@@ -0,0 +1,252 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
"""File reader skills for the OpenHands agent.
This module provides various functions to parse and extract content from different file types,
including PDF, DOCX, LaTeX, audio, image, video, and PowerPoint files. It utilizes different
libraries and APIs to process these files and output their content or descriptions.
Functions:
parse_pdf(file_path: str) -> None: Parse and print content of a PDF file.
parse_docx(file_path: str) -> None: Parse and print content of a DOCX file.
parse_latex(file_path: str) -> None: Parse and print content of a LaTeX file.
parse_audio(file_path: str, model: str = 'whisper-1') -> None: Transcribe and print content of an audio file.
parse_image(file_path: str, task: str = 'Describe this image as detail as possible.') -> None: Analyze and print description of an image file.
parse_video(file_path: str, task: str = 'Describe this image as detail as possible.', frame_interval: int = 30) -> None: Analyze and print description of video frames.
parse_pptx(file_path: str) -> None: Parse and print content of a PowerPoint file.
Note:
Some functions (parse_audio, parse_video, parse_image) require OpenAI API credentials
and are only available if the necessary environment variables are set.
"""
import base64
from typing import Any
import docx
import pypdf
from pptx import Presentation
from pylatexenc.latex2text import LatexNodes2Text
from openhands.runtime.plugins.agent_skills.utils.config import (
_get_max_token,
_get_openai_api_key,
_get_openai_base_url,
_get_openai_client,
_get_openai_model,
)
def parse_pdf(file_path: str) -> None:
"""Parses the content of a PDF file and prints it.
Args:
file_path: str: The path to the file to open.
"""
print(f'[Reading PDF file from {file_path}]')
content = pypdf.PdfReader(file_path)
text = ''
for page_idx in range(len(content.pages)):
text += (
f'@@ Page {page_idx + 1} @@\n'
+ content.pages[page_idx].extract_text()
+ '\n\n'
)
print(text.strip())
def parse_docx(file_path: str) -> None:
"""Parses the content of a DOCX file and prints it.
Args:
file_path: str: The path to the file to open.
"""
print(f'[Reading DOCX file from {file_path}]')
content = docx.Document(file_path)
text = ''
for i, para in enumerate(content.paragraphs):
text += f'@@ Page {i + 1} @@\n' + para.text + '\n\n'
print(text)
def parse_latex(file_path: str) -> None:
"""Parses the content of a LaTex file and prints it.
Args:
file_path: str: The path to the file to open.
"""
print(f'[Reading LaTex file from {file_path}]')
with open(file_path) as f:
data = f.read()
text = LatexNodes2Text().latex_to_text(data)
print(text.strip())
def _base64_img(file_path: str) -> str:
with open(file_path, 'rb') as image_file:
encoded_image = base64.b64encode(image_file.read()).decode('utf-8')
return encoded_image
def _base64_video(file_path: str, frame_interval: int = 10) -> list[str]:
import cv2
video = cv2.VideoCapture(file_path)
base64_frames = []
frame_count = 0
while video.isOpened():
success, frame = video.read()
if not success:
break
if frame_count % frame_interval == 0:
_, buffer = cv2.imencode('.jpg', frame)
base64_frames.append(base64.b64encode(buffer).decode('utf-8'))
frame_count += 1
video.release()
return base64_frames
def _prepare_image_messages(task: str, base64_image: str) -> list[dict[str, Any]]:
return [
{
'role': 'user',
'content': [
{'type': 'text', 'text': task},
{
'type': 'image_url',
'image_url': {'url': f'data:image/jpeg;base64,{base64_image}'},
},
],
}
]
def parse_audio(file_path: str, model: str = 'whisper-1') -> None:
"""Parses the content of an audio file and prints it.
Args:
file_path: str: The path to the audio file to transcribe.
model: str: The audio model to use for transcription. Defaults to 'whisper-1'.
"""
print(f'[Transcribing audio file from {file_path}]')
try:
# TODO: record the COST of the API call
with open(file_path, 'rb') as audio_file:
transcript = _get_openai_client().audio.translations.create(
model=model, file=audio_file
)
print(transcript.text)
except Exception as e:
print(f'Error transcribing audio file: {e}')
def parse_image(
file_path: str, task: str = 'Describe this image as detail as possible.'
) -> None:
"""Parses the content of an image file and prints the description.
Args:
file_path: str: The path to the file to open.
task: str: The task description for the API call. Defaults to 'Describe this image as detail as possible.'.
"""
print(f'[Reading image file from {file_path}]')
# TODO: record the COST of the API call
try:
base64_image = _base64_img(file_path)
response = _get_openai_client().chat.completions.create(
model=_get_openai_model(),
messages=_prepare_image_messages(task, base64_image),
max_tokens=_get_max_token(),
)
content = response.choices[0].message.content
print(content)
except Exception as error:
print(f'Error with the request: {error}')
def parse_video(
file_path: str,
task: str = 'Describe this image as detail as possible.',
frame_interval: int = 30,
) -> None:
"""Parses the content of an image file and prints the description.
Args:
file_path: str: The path to the video file to open.
task: str: The task description for the API call. Defaults to 'Describe this image as detail as possible.'.
frame_interval: int: The interval between frames to analyze. Defaults to 30.
"""
print(
f'[Processing video file from {file_path} with frame interval {frame_interval}]'
)
task = task or 'This is one frame from a video, please summarize this frame.'
base64_frames = _base64_video(file_path)
selected_frames = base64_frames[::frame_interval]
if len(selected_frames) > 30:
new_interval = len(base64_frames) // 30
selected_frames = base64_frames[::new_interval]
print(f'Totally {len(selected_frames)} would be analyze...\n')
idx = 0
for base64_frame in selected_frames:
idx += 1
print(f'Process the {file_path}, current No. {idx * frame_interval} frame...')
# TODO: record the COST of the API call
try:
response = _get_openai_client().chat.completions.create(
model=_get_openai_model(),
messages=_prepare_image_messages(task, base64_frame),
max_tokens=_get_max_token(),
)
content = response.choices[0].message.content
current_frame_content = f"Frame {idx}'s content: {content}\n"
print(current_frame_content)
except Exception as error:
print(f'Error with the request: {error}')
def parse_pptx(file_path: str) -> None:
"""Parses the content of a pptx file and prints it.
Args:
file_path: str: The path to the file to open.
"""
print(f'[Reading PowerPoint file from {file_path}]')
try:
pres = Presentation(str(file_path))
text = []
for slide_idx, slide in enumerate(pres.slides):
text.append(f'@@ Slide {slide_idx + 1} @@')
for shape in slide.shapes:
if hasattr(shape, 'text'):
text.append(shape.text)
print('\n'.join(text))
except Exception as e:
print(f'Error reading PowerPoint file: {e}')
__all__ = [
'parse_pdf',
'parse_docx',
'parse_latex',
'parse_pptx',
]
# This is called from OpenHands's side
# If SANDBOX_ENV_OPENAI_API_KEY is set, we will be able to use these tools in the sandbox environment
if _get_openai_api_key() and _get_openai_base_url():
__all__ += ['parse_audio', 'parse_video', 'parse_image']
@@ -0,0 +1,14 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
from openhands.runtime.plugins.agent_skills.repo_ops import repo_ops
from openhands.runtime.plugins.agent_skills.utils.dependency import import_functions
import_functions(
module=repo_ops, function_names=repo_ops.__all__, target_globals=globals()
)
__all__ = repo_ops.__all__
@@ -0,0 +1,18 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
from openhands_aci.indexing.locagent.tools import (
explore_tree_structure,
get_entity_contents,
search_code_snippets,
)
__all__ = [
'get_entity_contents',
'search_code_snippets',
'explore_tree_structure',
]
@@ -0,0 +1,37 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
import os
from openai import OpenAI
# ==================================================================================================
# OPENAI
# TODO: Move this to EventStream Actions when DockerRuntime is fully implemented
# NOTE: we need to get env vars inside functions because they will be set in IPython
# AFTER the agentskills is imported (the case for DockerRuntime)
# ==================================================================================================
def _get_openai_api_key() -> str:
return os.getenv('OPENAI_API_KEY', os.getenv('SANDBOX_ENV_OPENAI_API_KEY', ''))
def _get_openai_base_url() -> str:
return os.getenv('OPENAI_BASE_URL', 'https://api.openai.com/v1')
def _get_openai_model() -> str:
return os.getenv('OPENAI_MODEL', 'gpt-4o')
def _get_max_token() -> int:
return int(os.getenv('MAX_TOKEN', '500'))
def _get_openai_client() -> OpenAI:
client = OpenAI(api_key=_get_openai_api_key(), base_url=_get_openai_base_url())
return client
@@ -0,0 +1,18 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
from types import ModuleType
def import_functions(
module: ModuleType, function_names: list[str], target_globals: dict[str, object]
) -> None:
for name in function_names:
if hasattr(module, name):
target_globals[name] = getattr(module, name)
else:
raise ValueError(f'Function {name} not found in {module.__name__}')
@@ -0,0 +1,190 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
import asyncio
import os
import subprocess
import sys
import time
from dataclasses import dataclass
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import Action, IPythonRunCellAction
from openhands.events.observation import IPythonRunCellObservation
from openhands.runtime.plugins.jupyter.execute_server import JupyterKernel
from openhands.runtime.plugins.requirement import Plugin, PluginRequirement
from openhands.runtime.utils import find_available_tcp_port
from openhands.utils.shutdown_listener import should_continue
SU_TO_USER = os.getenv('SU_TO_USER', 'true').lower() in (
'1',
'true',
't',
'yes',
'y',
'on',
)
@dataclass
class JupyterRequirement(PluginRequirement):
name: str = 'jupyter'
class JupyterPlugin(Plugin):
name: str = 'jupyter'
kernel_gateway_port: int
kernel_id: str
gateway_process: asyncio.subprocess.Process | subprocess.Popen
python_interpreter_path: str
async def initialize(
self, username: str, kernel_id: str = 'openhands-default'
) -> None:
self.kernel_gateway_port = find_available_tcp_port(40000, 49999)
self.kernel_id = kernel_id
is_local_runtime = os.environ.get('LOCAL_RUNTIME_MODE') == '1'
is_windows = sys.platform == 'win32'
if not is_local_runtime:
# Non-LocalRuntime
prefix = f'su - {username} -s ' if SU_TO_USER else ''
# cd to code repo, setup all env vars and run micromamba
poetry_prefix = (
'cd /openhands/code\n'
'export POETRY_VIRTUALENVS_PATH=/openhands/poetry;\n'
'export PYTHONPATH=/openhands/code:$PYTHONPATH;\n'
'export MAMBA_ROOT_PREFIX=/openhands/micromamba;\n'
'/openhands/micromamba/bin/micromamba run -n openhands '
)
else:
# LocalRuntime
prefix = ''
code_repo_path = os.environ.get('OPENHANDS_REPO_PATH')
if not code_repo_path:
raise ValueError(
'OPENHANDS_REPO_PATH environment variable is not set. '
'This is required for the jupyter plugin to work with LocalRuntime.'
)
# The correct environment is ensured by the PATH in LocalRuntime.
poetry_prefix = f'cd {code_repo_path}\n'
if is_windows:
# Windows-specific command format
jupyter_launch_command = (
f'cd /d "{code_repo_path}" && '
f'"{sys.executable}" -m jupyter kernelgateway '
'--KernelGatewayApp.ip=0.0.0.0 '
f'--KernelGatewayApp.port={self.kernel_gateway_port}'
)
logger.debug(f'Jupyter launch command (Windows): {jupyter_launch_command}')
# Using synchronous subprocess.Popen for Windows as asyncio.create_subprocess_shell
# has limitations on Windows platforms
self.gateway_process = subprocess.Popen( # type: ignore[ASYNC101]
jupyter_launch_command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
shell=True,
text=True,
)
# Windows-specific stdout handling with synchronous time.sleep
# as asyncio has limitations on Windows for subprocess operations
output = ''
while should_continue():
if self.gateway_process.stdout is None:
time.sleep(1) # type: ignore[ASYNC101]
continue
line = self.gateway_process.stdout.readline()
if not line:
time.sleep(1) # type: ignore[ASYNC101]
continue
output += line
if 'at' in line:
break
time.sleep(1) # type: ignore[ASYNC101]
logger.debug('Waiting for jupyter kernel gateway to start...')
logger.debug(
f'Jupyter kernel gateway started at port {self.kernel_gateway_port}. Output: {output}'
)
else:
# Unix systems (Linux/macOS)
jupyter_launch_command = (
f"{prefix}/bin/bash << 'EOF'\n"
f'{poetry_prefix}'
f'"{sys.executable}" -m jupyter kernelgateway '
'--KernelGatewayApp.ip=0.0.0.0 '
f'--KernelGatewayApp.port={self.kernel_gateway_port}\n'
'EOF'
)
logger.debug(f'Jupyter launch command: {jupyter_launch_command}')
# Using asyncio.create_subprocess_shell instead of subprocess.Popen
# to avoid ASYNC101 linting error
self.gateway_process = await asyncio.create_subprocess_shell(
jupyter_launch_command,
stderr=asyncio.subprocess.STDOUT,
stdout=asyncio.subprocess.PIPE,
)
# read stdout until the kernel gateway is ready
output = ''
while should_continue() and self.gateway_process.stdout is not None:
line_bytes = await self.gateway_process.stdout.readline()
line = line_bytes.decode('utf-8')
output += line
if 'at' in line:
break
await asyncio.sleep(1)
logger.debug('Waiting for jupyter kernel gateway to start...')
logger.debug(
f'Jupyter kernel gateway started at port {self.kernel_gateway_port}. Output: {output}'
)
_obs = await self.run(
IPythonRunCellAction(code='import sys; print(sys.executable)')
)
self.python_interpreter_path = _obs.content.strip()
async def _run(self, action: Action) -> IPythonRunCellObservation:
"""Internal method to run a code cell in the jupyter kernel."""
if not isinstance(action, IPythonRunCellAction):
raise ValueError(
f'Jupyter plugin only supports IPythonRunCellAction, but got {action}'
)
if not hasattr(self, 'kernel'):
self.kernel = JupyterKernel(
f'localhost:{self.kernel_gateway_port}', self.kernel_id
)
if not self.kernel.initialized:
await self.kernel.initialize()
# Execute the code and get structured output
if action.timeout:
output = await self.kernel.execute(action.code, timeout=int(action.timeout))
else:
output = await self.kernel.execute(action.code)
# Extract text content and image URLs from the structured output
text_content = output.get('text', '')
image_urls = output.get('images', [])
return IPythonRunCellObservation(
content=text_content, # type: ignore[arg-type]
code=action.code,
image_urls=image_urls if image_urls else None, # type: ignore[arg-type]
)
async def run(self, action: Action) -> IPythonRunCellObservation:
obs = await self._run(action)
return obs
@@ -0,0 +1,316 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
#!/usr/bin/env python3
import asyncio
import logging
import os
import re
from uuid import uuid4
import tornado
import tornado.websocket
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
from tornado.escape import json_decode, json_encode, url_escape
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
from tornado.ioloop import PeriodicCallback
from tornado.websocket import websocket_connect
logging.basicConfig(level=logging.INFO)
def strip_ansi(o: str) -> str:
"""Removes ANSI escape sequences from `o`, as defined by ECMA-048 in
http://www.ecma-international.org/publications/files/ECMA-ST/Ecma-048.pdf
# https://github.com/ewen-lbh/python-strip-ansi/blob/master/strip_ansi/__init__.py
>>> strip_ansi("\\033[33mLorem ipsum\\033[0m")
'Lorem ipsum'
>>> strip_ansi("Lorem \\033[38;25mIpsum\\033[0m sit\\namet.")
'Lorem Ipsum sit\\namet.'
>>> strip_ansi("")
''
>>> strip_ansi("\\x1b[0m")
''
>>> strip_ansi("Lorem")
'Lorem'
>>> strip_ansi('\\x1b[38;5;32mLorem ipsum\\x1b[0m')
'Lorem ipsum'
>>> strip_ansi('\\x1b[1m\\x1b[46m\\x1b[31mLorem dolor sit ipsum\\x1b[0m')
'Lorem dolor sit ipsum'
"""
# pattern = re.compile(r'/(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]/')
pattern = re.compile(r'\x1B\[\d+(;\d+){0,2}m')
stripped = pattern.sub('', o)
return stripped
class JupyterKernel:
def __init__(self, url_suffix: str, convid: str, lang: str = 'python') -> None:
self.base_url = f'http://{url_suffix}'
self.base_ws_url = f'ws://{url_suffix}'
self.lang = lang
self.kernel_id: str | None = None
self.ws: tornado.websocket.WebSocketClientConnection | None = None
self.convid = convid
logging.info(
f'Jupyter kernel created for conversation {convid} at {url_suffix}'
)
self.heartbeat_interval = 10000 # 10 seconds
self.heartbeat_callback: PeriodicCallback | None = None
self.initialized = False
async def initialize(self) -> None:
await self.execute(r'%colors nocolor')
# pre-defined tools
self.tools_to_run: list[str] = [
# TODO: You can add code for your pre-defined tools here
]
for tool in self.tools_to_run:
res = await self.execute(tool)
logging.info(f'Tool [{tool}] initialized:\n{res}')
self.initialized = True
async def _send_heartbeat(self) -> None:
if not self.ws:
return
try:
self.ws.ping()
# logging.info('Heartbeat sent...')
except tornado.iostream.StreamClosedError:
# logging.info('Heartbeat failed, reconnecting...')
try:
await self._connect()
except ConnectionRefusedError:
logging.info(
'ConnectionRefusedError: Failed to reconnect to kernel websocket - Is the kernel still running?'
)
async def _connect(self) -> None:
if self.ws:
self.ws.close()
self.ws = None
client = AsyncHTTPClient()
if not self.kernel_id:
n_tries = 5
while n_tries > 0:
try:
response = await client.fetch(
'{}/api/kernels'.format(self.base_url),
method='POST',
body=json_encode({'name': self.lang}),
)
kernel = json_decode(response.body)
self.kernel_id = kernel['id']
break
except Exception:
# kernels are not ready yet
n_tries -= 1
await asyncio.sleep(1)
if n_tries == 0:
raise ConnectionRefusedError('Failed to connect to kernel')
ws_req = HTTPRequest(
url='{}/api/kernels/{}/channels'.format(
self.base_ws_url, url_escape(self.kernel_id)
)
)
self.ws = await websocket_connect(ws_req)
logging.info('Connected to kernel websocket')
# Setup heartbeat
if self.heartbeat_callback:
self.heartbeat_callback.stop()
self.heartbeat_callback = PeriodicCallback(
self._send_heartbeat, self.heartbeat_interval
)
self.heartbeat_callback.start()
@retry(
retry=retry_if_exception_type(ConnectionRefusedError),
stop=stop_after_attempt(3),
wait=wait_fixed(2),
) # type: ignore
async def execute(
self, code: str, timeout: int = 120
) -> dict[str, list[str] | str]:
if not self.ws or self.ws.stream.closed():
await self._connect()
msg_id = uuid4().hex
assert self.ws is not None
res = await self.ws.write_message(
json_encode(
{
'header': {
'username': '',
'version': '5.0',
'session': '',
'msg_id': msg_id,
'msg_type': 'execute_request',
},
'parent_header': {},
'channel': 'shell',
'content': {
'code': code,
'silent': False,
'store_history': False,
'user_expressions': {},
'allow_stdin': False,
},
'metadata': {},
'buffers': {},
}
)
)
logging.info(f'Executed code in jupyter kernel:\n{res}')
outputs: list[dict] = []
async def wait_for_messages() -> bool:
execution_done = False
while not execution_done:
assert self.ws is not None
msg = await self.ws.read_message()
if msg is None:
continue
msg_dict = json_decode(msg)
msg_type = msg_dict['msg_type']
parent_msg_id = msg_dict['parent_header'].get('msg_id', None)
if parent_msg_id != msg_id:
continue
if os.environ.get('DEBUG'):
logging.info(
f'MSG TYPE: {msg_type.upper()} DONE:{execution_done}\nCONTENT: {msg_dict["content"]}'
)
if msg_type == 'error':
traceback = '\n'.join(msg_dict['content']['traceback'])
outputs.append({'type': 'text', 'content': traceback})
execution_done = True
elif msg_type == 'stream':
outputs.append(
{'type': 'text', 'content': msg_dict['content']['text']}
)
elif msg_type in ['execute_result', 'display_data']:
outputs.append(
{
'type': 'text',
'content': msg_dict['content']['data']['text/plain'],
}
)
if 'image/png' in msg_dict['content']['data']:
# Store image data in structured format
image_url = f'data:image/png;base64,{msg_dict["content"]["data"]["image/png"]}'
outputs.append({'type': 'image', 'content': image_url})
elif msg_type == 'execute_reply':
execution_done = True
return execution_done
async def interrupt_kernel() -> None:
client = AsyncHTTPClient()
if self.kernel_id is None:
return
interrupt_response = await client.fetch(
f'{self.base_url}/api/kernels/{self.kernel_id}/interrupt',
method='POST',
body=json_encode({'kernel_id': self.kernel_id}),
)
logging.info(f'Kernel interrupted: {interrupt_response}')
try:
execution_done = await asyncio.wait_for(wait_for_messages(), timeout)
except asyncio.TimeoutError:
await interrupt_kernel()
return {'text': f'[Execution timed out ({timeout} seconds).]', 'images': []}
# Process structured outputs
text_outputs = []
image_outputs = []
for output in outputs:
if output['type'] == 'text':
text_outputs.append(output['content'])
elif output['type'] == 'image':
image_outputs.append(output['content'])
if not text_outputs and execution_done:
text_content = '[Code executed successfully with no output]'
else:
text_content = ''.join(text_outputs)
# Remove ANSI from text content
text_content = strip_ansi(text_content)
# Return a dictionary with text content and image URLs
return {'text': text_content, 'images': image_outputs}
async def shutdown_async(self) -> None:
if self.kernel_id:
client = AsyncHTTPClient()
await client.fetch(
'{}/api/kernels/{}'.format(self.base_url, self.kernel_id),
method='DELETE',
)
self.kernel_id = None
if self.ws:
self.ws.close()
self.ws = None
class ExecuteHandler(tornado.web.RequestHandler):
def initialize(self, jupyter_kernel: JupyterKernel) -> None:
self.jupyter_kernel = jupyter_kernel
async def post(self) -> None:
data = json_decode(self.request.body)
code = data.get('code')
if not code:
self.set_status(400)
self.write('Missing code')
return
output = await self.jupyter_kernel.execute(code)
# Set content type to JSON and return the structured output
self.set_header('Content-Type', 'application/json')
self.write(json_encode(output))
def make_app() -> tornado.web.Application:
jupyter_kernel = JupyterKernel(
f'localhost:{os.environ.get("JUPYTER_GATEWAY_PORT", "8888")}',
os.environ.get('JUPYTER_GATEWAY_KERNEL_ID', 'default'),
)
asyncio.get_event_loop().run_until_complete(jupyter_kernel.initialize())
return tornado.web.Application(
[
(r'/execute', ExecuteHandler, {'jupyter_kernel': jupyter_kernel}),
]
)
if __name__ == '__main__':
app = make_app()
app.listen(os.environ.get('JUPYTER_EXEC_SERVER_PORT'))
tornado.ioloop.IOLoop.current().start()
+38
View File
@@ -0,0 +1,38 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
from abc import abstractmethod
from dataclasses import dataclass
from openhands.events.action import Action
from openhands.events.observation import Observation
class Plugin:
"""Base class for a plugin.
This will be initialized by the runtime client, which will run inside docker.
"""
name: str
@abstractmethod
async def initialize(self, username: str) -> None:
"""Initialize the plugin."""
pass
@abstractmethod
async def run(self, action: Action) -> Observation:
"""Run the plugin for a given action."""
pass
@dataclass
class PluginRequirement:
"""Requirement for a plugin."""
name: str
@@ -0,0 +1,164 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
import asyncio
import os
import shutil
import sys
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import Action
from openhands.events.observation import Observation
from openhands.runtime.plugins.requirement import Plugin, PluginRequirement
from openhands.runtime.utils.system import check_port_available
from openhands.utils.shutdown_listener import should_continue
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
SU_TO_USER = os.getenv('SU_TO_USER', 'true').lower() in (
'1',
'true',
't',
'yes',
'y',
'on',
)
@dataclass
class VSCodeRequirement(PluginRequirement):
name: str = 'vscode'
class VSCodePlugin(Plugin):
name: str = 'vscode'
vscode_port: Optional[int] = None
vscode_connection_token: Optional[str] = None
gateway_process: asyncio.subprocess.Process
async def initialize(self, username: str, runtime_id: str | None = None) -> None:
# Check if we're on Windows - VSCode plugin is not supported on Windows
if os.name == 'nt' or sys.platform == 'win32':
self.vscode_port = None
self.vscode_connection_token = None
logger.warning(
'VSCode plugin is not supported on Windows. Plugin will be disabled.'
)
return
if username not in filter(None, [RUNTIME_USERNAME, 'root', 'openhands']):
self.vscode_port = None
self.vscode_connection_token = None
logger.warning(
'VSCodePlugin is only supported for root or openhands user. '
'It is not yet supported for other users (i.e., when running LocalRuntime).'
)
return
# Set up VSCode settings.json
self._setup_vscode_settings()
try:
self.vscode_port = int(os.environ['VSCODE_PORT'])
except (KeyError, ValueError):
logger.warning(
'VSCODE_PORT environment variable not set or invalid. VSCode plugin will be disabled.'
)
return
self.vscode_connection_token = str(uuid.uuid4())
if not check_port_available(self.vscode_port):
logger.warning(
f'Port {self.vscode_port} is not available. VSCode plugin will be disabled.'
)
return
workspace_path = os.getenv('WORKSPACE_MOUNT_PATH_IN_SANDBOX', '/workspace')
# Compute base path for OpenVSCode Server when running behind a path-based router
base_path_flag = ''
# Allow explicit override via environment
explicit_base = os.getenv('OPENVSCODE_SERVER_BASE_PATH')
if explicit_base:
explicit_base = (
explicit_base if explicit_base.startswith('/') else f'/{explicit_base}'
)
base_path_flag = f' --server-base-path {explicit_base.rstrip("/")}'
else:
# If runtime_id passed explicitly (preferred), use it
runtime_url = os.getenv('RUNTIME_URL', '')
if runtime_url and runtime_id:
parsed = urlparse(runtime_url)
path = parsed.path or '/'
path_mode = path.startswith(f'/{runtime_id}')
if path_mode:
base_path_flag = f' --server-base-path /{runtime_id}/vscode'
cmd = (
(
f"su - {username} -s /bin/bash << 'EOF'\n"
if SU_TO_USER
else "/bin/bash << 'EOF'\n"
)
+ f'sudo chown -R {username}:{username} /openhands/.openvscode-server\n'
+ f'cd {workspace_path}\n'
+ 'exec /openhands/.openvscode-server/bin/openvscode-server '
+ f'--host 0.0.0.0 --connection-token {self.vscode_connection_token} '
+ f'--port {self.vscode_port} --disable-workspace-trust{base_path_flag}\n'
+ 'EOF'
)
# Using asyncio.create_subprocess_shell instead of subprocess.Popen
# to avoid ASYNC101 linting error
self.gateway_process = await asyncio.create_subprocess_shell(
cmd,
stderr=asyncio.subprocess.STDOUT,
stdout=asyncio.subprocess.PIPE,
)
# read stdout until the kernel gateway is ready
output = ''
while should_continue() and self.gateway_process.stdout is not None:
line_bytes = await self.gateway_process.stdout.readline()
line = line_bytes.decode('utf-8')
print(line)
output += line
if 'at' in line:
break
await asyncio.sleep(1)
logger.debug('Waiting for VSCode server to start...')
logger.debug(
f'VSCode server started at port {self.vscode_port}. Output: {output}'
)
def _setup_vscode_settings(self) -> None:
"""Set up VSCode settings by creating the .vscode directory in the workspace
and copying the settings.json file there.
"""
# Get the path to the settings.json file in the plugin directory
current_dir = Path(__file__).parent
settings_path = current_dir / 'settings.json'
# Create the .vscode directory in the workspace if it doesn't exist
workspace_dir = Path(os.getenv('WORKSPACE_BASE', '/workspace'))
vscode_dir = workspace_dir / '.vscode'
vscode_dir.mkdir(parents=True, exist_ok=True)
# Copy the settings.json file to the .vscode directory
target_path = vscode_dir / 'settings.json'
shutil.copy(settings_path, target_path)
# Make sure the settings file is readable and writable by all users
os.chmod(target_path, 0o666)
logger.debug(f'VSCode settings copied to {target_path}')
async def run(self, action: Action) -> Observation:
"""Run the plugin for a given action."""
raise NotImplementedError('VSCodePlugin does not support run method')
@@ -0,0 +1,5 @@
{
"workbench.colorTheme": "Default Dark Modern",
"workbench.startupEditor": "none",
"chat.commandCenter.enabled": false
}
+31
View File
@@ -0,0 +1,31 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
from enum import Enum
class RuntimeStatus(Enum):
STOPPED = 'STATUS$STOPPED'
BUILDING_RUNTIME = 'STATUS$BUILDING_RUNTIME'
STARTING_RUNTIME = 'STATUS$STARTING_RUNTIME'
RUNTIME_STARTED = 'STATUS$RUNTIME_STARTED'
SETTING_UP_WORKSPACE = 'STATUS$SETTING_UP_WORKSPACE'
SETTING_UP_GIT_HOOKS = 'STATUS$SETTING_UP_GIT_HOOKS'
READY = 'STATUS$READY'
ERROR = 'STATUS$ERROR'
ERROR_RUNTIME_DISCONNECTED = 'STATUS$ERROR_RUNTIME_DISCONNECTED'
ERROR_LLM_AUTHENTICATION = 'STATUS$ERROR_LLM_AUTHENTICATION'
ERROR_LLM_SERVICE_UNAVAILABLE = 'STATUS$ERROR_LLM_SERVICE_UNAVAILABLE'
ERROR_LLM_INTERNAL_SERVER_ERROR = 'STATUS$ERROR_LLM_INTERNAL_SERVER_ERROR'
ERROR_LLM_OUT_OF_CREDITS = 'STATUS$ERROR_LLM_OUT_OF_CREDITS'
ERROR_LLM_CONTENT_POLICY_VIOLATION = 'STATUS$ERROR_LLM_CONTENT_POLICY_VIOLATION'
AGENT_RATE_LIMITED_STOPPED_MESSAGE = (
'CHAT_INTERFACE$AGENT_RATE_LIMITED_STOPPED_MESSAGE'
)
GIT_PROVIDER_AUTHENTICATION_ERROR = 'STATUS$GIT_PROVIDER_AUTHENTICATION_ERROR'
LLM_RETRY = 'STATUS$LLM_RETRY'
ERROR_MEMORY = 'STATUS$ERROR_MEMORY'
+13
View File
@@ -0,0 +1,13 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
from openhands.runtime.utils.system import (
display_number_matrix,
find_available_tcp_port,
)
__all__ = ['display_number_matrix', 'find_available_tcp_port']
+692
View File
@@ -0,0 +1,692 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
import os
import re
import time
import uuid
from enum import Enum
from typing import Any
import bashlex
import libtmux
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import CmdRunAction
from openhands.events.observation import ErrorObservation
from openhands.events.observation.commands import (
CMD_OUTPUT_PS1_END,
CmdOutputMetadata,
CmdOutputObservation,
)
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
from openhands.utils.shutdown_listener import should_continue
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
SU_TO_USER = os.getenv('SU_TO_USER', 'true').lower() in (
'1',
'true',
't',
'yes',
'y',
'on',
)
def split_bash_commands(commands: str) -> list[str]:
if not commands.strip():
return ['']
try:
parsed = bashlex.parse(commands)
except (
bashlex.errors.ParsingError,
NotImplementedError,
TypeError,
AttributeError,
):
# Added AttributeError to catch 'str' object has no attribute 'kind' error (issue #8369)
logger.debug(
f'Failed to parse bash commands\n'
f'[input]: {commands}\n'
f'The original command will be returned as is.',
exc_info=True,
)
# If parsing fails, return the original commands
return [commands]
result: list[str] = []
last_end = 0
for node in parsed:
start, end = node.pos
# Include any text between the last command and this one
if start > last_end:
between = commands[last_end:start]
logger.debug(f'BASH PARSING between: {between}')
if result:
result[-1] += between.rstrip()
elif between.strip():
# THIS SHOULD NOT HAPPEN
result.append(between.rstrip())
# Extract the command, preserving original formatting
command = commands[start:end].rstrip()
logger.debug(f'BASH PARSING command: {command}')
result.append(command)
last_end = end
# Add any remaining text after the last command to the last command
remaining = commands[last_end:].rstrip()
logger.debug(f'BASH PARSING remaining: {remaining}')
if last_end < len(commands) and result:
result[-1] += remaining
logger.debug(f'BASH PARSING result[-1] += remaining: {result[-1]}')
elif last_end < len(commands):
if remaining:
result.append(remaining)
logger.debug(f'BASH PARSING result.append(remaining): {result[-1]}')
return result
def escape_bash_special_chars(command: str) -> str:
r"""Escapes characters that have different interpretations in bash vs python.
Specifically handles escape sequences like \;, \|, \&, etc.
"""
if command.strip() == '':
return ''
try:
parts = []
last_pos = 0
def visit_node(node: Any) -> None:
nonlocal last_pos
if (
node.kind == 'redirect'
and hasattr(node, 'heredoc')
and node.heredoc is not None
):
# We're entering a heredoc - preserve everything as-is until we see EOF
# Store the heredoc end marker (usually 'EOF' but could be different)
between = command[last_pos : node.pos[0]]
parts.append(between)
# Add the heredoc start marker
parts.append(command[node.pos[0] : node.heredoc.pos[0]])
# Add the heredoc content as-is
parts.append(command[node.heredoc.pos[0] : node.heredoc.pos[1]])
last_pos = node.pos[1]
return
if node.kind == 'word':
# Get the raw text between the last position and current word
between = command[last_pos : node.pos[0]]
word_text = command[node.pos[0] : node.pos[1]]
# Add the between text, escaping special characters
between = re.sub(r'\\([;&|><])', r'\\\\\1', between)
parts.append(between)
# Check if word_text is a quoted string or command substitution
if (
(word_text.startswith('"') and word_text.endswith('"'))
or (word_text.startswith("'") and word_text.endswith("'"))
or (word_text.startswith('$(') and word_text.endswith(')'))
or (word_text.startswith('`') and word_text.endswith('`'))
):
# Preserve quoted strings, command substitutions, and heredoc content as-is
parts.append(word_text)
else:
# Escape special chars in unquoted text
word_text = re.sub(r'\\([;&|><])', r'\\\\\1', word_text)
parts.append(word_text)
last_pos = node.pos[1]
return
# Visit child nodes
if hasattr(node, 'parts'):
for part in node.parts:
visit_node(part)
# Process all nodes in the AST
nodes = list(bashlex.parse(command))
for node in nodes:
between = command[last_pos : node.pos[0]]
between = re.sub(r'\\([;&|><])', r'\\\\\1', between)
parts.append(between)
last_pos = node.pos[0]
visit_node(node)
# Handle any remaining text after the last word
remaining = command[last_pos:]
parts.append(remaining)
return ''.join(parts)
except (bashlex.errors.ParsingError, NotImplementedError, TypeError):
logger.debug(
f'Failed to parse bash commands for special characters escape\n'
f'[input]: {command}\n'
f'The original command will be returned as is.',
exc_info=True,
)
return command
class BashCommandStatus(Enum):
CONTINUE = 'continue'
COMPLETED = 'completed'
NO_CHANGE_TIMEOUT = 'no_change_timeout'
HARD_TIMEOUT = 'hard_timeout'
def _remove_command_prefix(command_output: str, command: str) -> str:
return command_output.lstrip().removeprefix(command.lstrip()).lstrip()
class BashSession:
POLL_INTERVAL = 0.5
HISTORY_LIMIT = 10_000
PS1 = CmdOutputMetadata.to_ps1_prompt()
def __init__(
self,
work_dir: str,
username: str | None = None,
no_change_timeout_seconds: int = 30,
max_memory_mb: int | None = None,
):
self.NO_CHANGE_TIMEOUT_SECONDS = no_change_timeout_seconds
self.work_dir = work_dir
self.username = username
self._initialized = False
self.max_memory_mb = max_memory_mb
def initialize(self) -> None:
self.server = libtmux.Server()
_shell_command = '/bin/bash'
if SU_TO_USER and self.username in list(
filter(None, [RUNTIME_USERNAME, 'root', 'openhands'])
):
# This starts a non-login (new) shell for the given user
_shell_command = f'su {self.username} -'
# FIXME: we will introduce memory limit using sysbox-runc in coming PR
# # otherwise, we are running as the CURRENT USER (e.g., when running LocalRuntime)
# if self.max_memory_mb is not None:
# window_command = (
# f'prlimit --as={self.max_memory_mb * 1024 * 1024} {_shell_command}'
# )
# else:
window_command = _shell_command
logger.debug(
f'Initializing bash session in {self.work_dir} with command: {window_command}'
)
session_name = f'openhands-{self.username}-{uuid.uuid4()}'
self.session = self.server.new_session(
session_name=session_name,
start_directory=self.work_dir, # This parameter is supported by libtmux
kill_session=True,
x=1000,
y=1000,
)
# Set history limit to a large number to avoid losing history
# https://unix.stackexchange.com/questions/43414/unlimited-history-in-tmux
self.session.set_option('history-limit', str(self.HISTORY_LIMIT), global_=True)
self.session.history_limit = str(self.HISTORY_LIMIT)
# We need to create a new pane because the initial pane's history limit is (default) 2000
_initial_window = self.session.active_window
self.window = self.session.new_window(
window_name='bash',
window_shell=window_command,
start_directory=self.work_dir, # This parameter is supported by libtmux
)
self.pane = self.window.active_pane
logger.debug(f'pane: {self.pane}; history_limit: {self.session.history_limit}')
_initial_window.kill()
# Configure bash to use simple PS1 and disable PS2
if self.pane:
self.pane.send_keys(
f'export PROMPT_COMMAND=\'export PS1="{self.PS1}"\'; export PS2=""'
)
time.sleep(0.1) # Wait for command to take effect
self._clear_screen()
# Store the last command for interactive input handling
self.prev_status: BashCommandStatus | None = None
self.prev_output: str = ''
self._closed: bool = False
logger.debug(f'Bash session initialized with work dir: {self.work_dir}')
# Maintain the current working directory
self._cwd = os.path.abspath(self.work_dir)
self._initialized = True
def __del__(self) -> None:
"""Ensure the session is closed when the object is destroyed."""
self.close()
def _get_pane_content(self) -> str:
"""Capture the current pane content and update the buffer."""
if not self.pane:
return ''
content = '\n'.join(
map(
# avoid double newlines
lambda line: line.rstrip(),
self.pane.cmd('capture-pane', '-J', '-pS', '-').stdout,
)
)
return content
def close(self) -> None:
"""Clean up the session."""
if self._closed:
return
self.session.kill()
self._closed = True
@property
def cwd(self) -> str:
return self._cwd
def _is_special_key(self, command: str) -> bool:
"""Check if the command is a special key."""
# Special keys are of the form C-<key>
_command = command.strip()
return _command.startswith('C-') and len(_command) == 3
def _clear_screen(self) -> None:
"""Clear the tmux pane screen and history."""
if self.pane:
self.pane.send_keys('C-l', enter=False)
time.sleep(0.1)
self.pane.cmd('clear-history')
def _get_command_output(
self,
command: str,
raw_command_output: str,
metadata: CmdOutputMetadata,
continue_prefix: str = '',
) -> str:
"""Get the command output with the previous command output removed.
Args:
command: The command that was executed.
raw_command_output: The raw output from the command.
metadata: The metadata object to store prefix/suffix in.
continue_prefix: The prefix to add to the command output if it's a continuation of the previous command.
"""
# remove the previous command output from the new output if any
if self.prev_output:
command_output = raw_command_output.removeprefix(self.prev_output)
metadata.prefix = continue_prefix
else:
command_output = raw_command_output
self.prev_output = raw_command_output # update current command output anyway
command_output = _remove_command_prefix(command_output, command)
return command_output.rstrip()
def _handle_completed_command(
self,
command: str,
pane_content: str,
ps1_matches: list[re.Match],
hidden: bool,
) -> CmdOutputObservation:
is_special_key = self._is_special_key(command)
assert len(ps1_matches) >= 1, (
f'Expected at least one PS1 metadata block, but got {len(ps1_matches)}.\n'
f'---FULL OUTPUT---\n{pane_content!r}\n---END OF OUTPUT---'
)
metadata = CmdOutputMetadata.from_ps1_match(ps1_matches[-1])
# Special case where the previous command output is truncated due to history limit
# We should get the content BEFORE the last PS1 prompt
get_content_before_last_match = bool(len(ps1_matches) == 1)
# Update the current working directory if it has changed
if metadata.working_dir != self._cwd and metadata.working_dir:
logger.debug(
f'directory_changed: {self._cwd}; {metadata.working_dir}; {command}'
)
self._cwd = metadata.working_dir
logger.debug(f'COMMAND OUTPUT: {pane_content}')
# Extract the command output between the two PS1 prompts
raw_command_output = self._combine_outputs_between_matches(
pane_content,
ps1_matches,
get_content_before_last_match=get_content_before_last_match,
)
if get_content_before_last_match:
# Count the number of lines in the truncated output
num_lines = len(raw_command_output.splitlines())
metadata.prefix = f'[Previous command outputs are truncated. Showing the last {num_lines} lines of the output below.]\n'
metadata.suffix = (
f'\n[The command completed with exit code {metadata.exit_code}.]'
if not is_special_key
else f'\n[The command completed with exit code {metadata.exit_code}. CTRL+{command[-1].upper()} was sent.]'
)
command_output = self._get_command_output(
command,
raw_command_output,
metadata,
)
self.prev_status = BashCommandStatus.COMPLETED
self.prev_output = '' # Reset previous command output
self._ready_for_next_command()
return CmdOutputObservation(
content=command_output,
command=command,
metadata=metadata,
hidden=hidden,
)
def _handle_nochange_timeout_command(
self,
command: str,
pane_content: str,
ps1_matches: list[re.Match],
) -> CmdOutputObservation:
self.prev_status = BashCommandStatus.NO_CHANGE_TIMEOUT
if len(ps1_matches) != 1:
logger.warning(
'Expected exactly one PS1 metadata block BEFORE the execution of a command, '
f'but got {len(ps1_matches)} PS1 metadata blocks:\n---\n{pane_content!r}\n---'
)
raw_command_output = self._combine_outputs_between_matches(
pane_content, ps1_matches
)
metadata = CmdOutputMetadata() # No metadata available
metadata.suffix = (
f'\n[The command has no new output after {self.NO_CHANGE_TIMEOUT_SECONDS} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
)
command_output = self._get_command_output(
command,
raw_command_output,
metadata,
continue_prefix='[Below is the output of the previous command.]\n',
)
return CmdOutputObservation(
content=command_output,
command=command,
metadata=metadata,
)
def _handle_hard_timeout_command(
self,
command: str,
pane_content: str,
ps1_matches: list[re.Match],
timeout: float,
) -> CmdOutputObservation:
self.prev_status = BashCommandStatus.HARD_TIMEOUT
if len(ps1_matches) != 1:
logger.warning(
'Expected exactly one PS1 metadata block BEFORE the execution of a command, '
f'but got {len(ps1_matches)} PS1 metadata blocks:\n---\n{pane_content!r}\n---'
)
raw_command_output = self._combine_outputs_between_matches(
pane_content, ps1_matches
)
metadata = CmdOutputMetadata() # No metadata available
metadata.suffix = (
f'\n[The command timed out after {timeout} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
)
command_output = self._get_command_output(
command,
raw_command_output,
metadata,
continue_prefix='[Below is the output of the previous command.]\n',
)
return CmdOutputObservation(
command=command,
content=command_output,
metadata=metadata,
)
def _ready_for_next_command(self) -> None:
"""Reset the content buffer for a new command."""
# Clear the current content
self._clear_screen()
def _combine_outputs_between_matches(
self,
pane_content: str,
ps1_matches: list[re.Match],
get_content_before_last_match: bool = False,
) -> str:
"""Combine all outputs between PS1 matches.
Args:
pane_content: The full pane content containing PS1 prompts and command outputs
ps1_matches: List of regex matches for PS1 prompts
get_content_before_last_match: when there's only one PS1 match, whether to get
the content before the last PS1 prompt (True) or after the last PS1 prompt (False)
Returns:
Combined string of all outputs between matches
"""
if len(ps1_matches) == 1:
if get_content_before_last_match:
# The command output is the content before the last PS1 prompt
return pane_content[: ps1_matches[0].start()]
else:
# The command output is the content after the last PS1 prompt
return pane_content[ps1_matches[0].end() + 1 :]
elif len(ps1_matches) == 0:
return pane_content
combined_output = ''
for i in range(len(ps1_matches) - 1):
# Extract content between current and next PS1 prompt
output_segment = pane_content[
ps1_matches[i].end() + 1 : ps1_matches[i + 1].start()
]
combined_output += output_segment + '\n'
# Add the content after the last PS1 prompt
combined_output += pane_content[ps1_matches[-1].end() + 1 :]
logger.debug(f'COMBINED OUTPUT: {combined_output}')
return combined_output
def execute(self, action: CmdRunAction) -> CmdOutputObservation | ErrorObservation:
"""Execute a command in the bash session."""
if not self._initialized:
raise RuntimeError('Bash session is not initialized')
# Strip the command of any leading/trailing whitespace
logger.debug(f'RECEIVED ACTION: {action}')
command = action.command.strip()
is_input: bool = action.is_input
# If the previous command is not completed, we need to check if the command is empty
if self.prev_status not in {
BashCommandStatus.CONTINUE,
BashCommandStatus.NO_CHANGE_TIMEOUT,
BashCommandStatus.HARD_TIMEOUT,
}:
if command == '':
return CmdOutputObservation(
content='ERROR: No previous running command to retrieve logs from.',
command='',
metadata=CmdOutputMetadata(),
)
if is_input:
return CmdOutputObservation(
content='ERROR: No previous running command to interact with.',
command='',
metadata=CmdOutputMetadata(),
)
# Check if the command is a single command or multiple commands
splited_commands = split_bash_commands(command)
if len(splited_commands) > 1:
return ErrorObservation(
content=(
f'ERROR: Cannot execute multiple commands at once.\n'
f'Please run each command separately OR chain them into a single command via && or ;\n'
f'Provided commands:\n{"\n".join(f"({i + 1}) {cmd}" for i, cmd in enumerate(splited_commands))}'
)
)
# Get initial state before sending command
initial_pane_output = self._get_pane_content()
initial_ps1_matches = CmdOutputMetadata.matches_ps1_metadata(
initial_pane_output
)
initial_ps1_count = len(initial_ps1_matches)
logger.debug(f'Initial PS1 count: {initial_ps1_count}')
start_time = time.time()
last_change_time = start_time
last_pane_output = (
initial_pane_output # Use initial output as the starting point
)
# When prev command is still running, and we are trying to send a new command
if (
self.prev_status
in {
BashCommandStatus.HARD_TIMEOUT,
BashCommandStatus.NO_CHANGE_TIMEOUT,
}
and not last_pane_output.rstrip().endswith(
CMD_OUTPUT_PS1_END.rstrip()
) # prev command is not completed
and not is_input
and command != '' # not input and not empty command
):
_ps1_matches = CmdOutputMetadata.matches_ps1_metadata(last_pane_output)
# Use initial_ps1_matches if _ps1_matches is empty, otherwise use _ps1_matches
# This handles the case where the prompt might be scrolled off screen but existed before
current_matches_for_output = (
_ps1_matches if _ps1_matches else initial_ps1_matches
)
raw_command_output = self._combine_outputs_between_matches(
last_pane_output, current_matches_for_output
)
metadata = CmdOutputMetadata() # No metadata available
metadata.suffix = (
f'\n[Your command "{command}" is NOT executed. '
'The previous command is still running - You CANNOT send new commands until the previous command is completed. '
'By setting `is_input` to `true`, you can interact with the current process: '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
)
logger.debug(f'PREVIOUS COMMAND OUTPUT: {raw_command_output}')
command_output = self._get_command_output(
command,
raw_command_output,
metadata,
continue_prefix='[Below is the output of the previous command.]\n',
)
return CmdOutputObservation(
command=command,
content=command_output,
metadata=metadata,
hidden=getattr(action, 'hidden', False),
)
# Send actual command/inputs to the pane
if command != '' and self.pane:
is_special_key = self._is_special_key(command)
if is_input:
logger.debug(f'SENDING INPUT TO RUNNING PROCESS: {command!r}')
self.pane.send_keys(
command,
enter=not is_special_key,
)
else:
# convert command to raw string
command = escape_bash_special_chars(command)
logger.debug(f'SENDING COMMAND: {command!r}')
self.pane.send_keys(
command,
enter=not is_special_key,
)
# Loop until the command completes or times out
while should_continue():
_start_time = time.time()
logger.debug(f'GETTING PANE CONTENT at {_start_time}')
cur_pane_output = self._get_pane_content()
logger.debug(
f'PANE CONTENT GOT after {time.time() - _start_time:.2f} seconds'
)
cur_pane_lines = cur_pane_output.split('\n')
if len(cur_pane_lines) <= 20:
logger.debug('PANE_CONTENT: {cur_pane_output}')
else:
logger.debug(f'BEGIN OF PANE CONTENT: {cur_pane_lines[:10]}')
logger.debug(f'END OF PANE CONTENT: {cur_pane_lines[-10:]}')
ps1_matches = CmdOutputMetadata.matches_ps1_metadata(cur_pane_output)
current_ps1_count = len(ps1_matches)
if cur_pane_output != last_pane_output:
last_pane_output = cur_pane_output
last_change_time = time.time()
logger.debug(f'CONTENT UPDATED DETECTED at {last_change_time}')
# 1) Execution completed:
# Condition 1: A new prompt has appeared since the command started.
# Condition 2: The prompt count hasn't increased (potentially because the initial one scrolled off),
# BUT the *current* visible pane ends with a prompt, indicating completion.
if (
current_ps1_count > initial_ps1_count
or cur_pane_output.rstrip().endswith(CMD_OUTPUT_PS1_END.rstrip())
):
return self._handle_completed_command(
command,
pane_content=cur_pane_output,
ps1_matches=ps1_matches,
hidden=getattr(action, 'hidden', False),
)
# Timeout checks should only trigger if a new prompt hasn't appeared yet.
# 2) Execution timed out since there's no change in output
# for a while (self.NO_CHANGE_TIMEOUT_SECONDS)
# We ignore this if the command is *blocking*
time_since_last_change = time.time() - last_change_time
logger.debug(
f'CHECKING NO CHANGE TIMEOUT ({self.NO_CHANGE_TIMEOUT_SECONDS}s): elapsed {time_since_last_change}. Action blocking: {action.blocking}'
)
if (
not action.blocking
and time_since_last_change >= self.NO_CHANGE_TIMEOUT_SECONDS
):
return self._handle_nochange_timeout_command(
command,
pane_content=cur_pane_output,
ps1_matches=ps1_matches,
)
# 3) Execution timed out due to hard timeout
elapsed_time = time.time() - start_time
logger.debug(
f'CHECKING HARD TIMEOUT ({action.timeout}s): elapsed {elapsed_time:.2f}'
)
if action.timeout and elapsed_time >= action.timeout:
logger.debug('Hard timeout triggered.')
return self._handle_hard_timeout_command(
command,
pane_content=cur_pane_output,
ps1_matches=ps1_matches,
timeout=action.timeout,
)
logger.debug(f'SLEEPING for {self.POLL_INTERVAL} seconds for next poll')
time.sleep(self.POLL_INTERVAL)
raise RuntimeError('Bash session was likely interrupted...')
+14
View File
@@ -0,0 +1,14 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
# Common timeout message that can be used across different timeout scenarios
TIMEOUT_MESSAGE_TEMPLATE = (
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'send keys ("C-c", "C-z", "C-d") to interrupt/kill the previous command before sending your new command, '
'or use the timeout parameter in execute_bash for future commands.'
)
+89
View File
@@ -0,0 +1,89 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
import os
from openhands.core.config import OpenHandsConfig
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.plugins import PluginRequirement
DEFAULT_PYTHON_PREFIX = [
'/openhands/micromamba/bin/micromamba',
'run',
'-n',
'openhands',
'poetry',
'run',
]
DEFAULT_MAIN_MODULE = 'openhands.runtime.action_execution_server'
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
RUNTIME_UID = os.getenv('RUNTIME_UID')
def get_action_execution_server_startup_command(
server_port: int,
plugins: list[PluginRequirement],
app_config: OpenHandsConfig,
python_prefix: list[str] = DEFAULT_PYTHON_PREFIX,
override_user_id: int | None = None,
override_username: str | None = None,
main_module: str = DEFAULT_MAIN_MODULE,
python_executable: str = 'python',
) -> list[str]:
sandbox_config = app_config.sandbox
logger.debug(f'app_config {vars(app_config)}')
logger.debug(f'sandbox_config {vars(sandbox_config)}')
logger.debug(f'RUNTIME_USERNAME {RUNTIME_USERNAME}, RUNTIME_UID {RUNTIME_UID}')
logger.debug(
f'override_username {override_username}, override_user_id {override_user_id}'
)
# Plugin args
plugin_args = []
if plugins is not None and len(plugins) > 0:
plugin_args = ['--plugins'] + [plugin.name for plugin in plugins]
# Browsergym stuffs
browsergym_args = []
if sandbox_config.browsergym_eval_env is not None:
browsergym_args = [
'--browsergym-eval-env'
] + sandbox_config.browsergym_eval_env.split(' ')
username = (
override_username
or RUNTIME_USERNAME
or ('openhands' if app_config.run_as_openhands else 'root')
)
user_id = (
override_user_id or RUNTIME_UID or (1000 if app_config.run_as_openhands else 0)
)
logger.debug(f'username {username}, user_id {user_id}')
base_cmd = [
*python_prefix,
python_executable,
'-u',
'-m',
main_module,
str(server_port),
'--working-dir',
app_config.workspace_mount_path_in_sandbox,
*plugin_args,
'--username',
username,
'--user-id',
str(user_id),
*browsergym_args,
]
if not app_config.enable_browser:
base_cmd.append('--no-enable-browser')
logger.debug(f'get_action_execution_server_startup_command: {base_cmd}')
return base_cmd
+321
View File
@@ -0,0 +1,321 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
import re
from abc import ABC, abstractmethod
from typing import Any
from openhands_aci.utils.diff import get_diff # type: ignore
from openhands.core.config import OpenHandsConfig
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import (
FileEditAction,
FileReadAction,
FileWriteAction,
IPythonRunCellAction,
)
from openhands.events.observation import (
ErrorObservation,
FileEditObservation,
FileReadObservation,
FileWriteObservation,
Observation,
)
from openhands.llm.llm import LLM
from openhands.llm.llm_registry import LLMRegistry
from openhands.utils.chunk_localizer import Chunk, get_top_k_chunk_matches
USER_MSG = """
Code changes will be provided in the form of a draft. You will need to apply the draft to the original code.
The original code will be enclosed within `<original_code>` tags.
The draft will be enclosed within `<update_snippet>` tags.
You need to output the update code within `<updated_code>` tags.
Within the `<updated_code>` tag, include only the final code after updation. Do not include any explanations or other content within these tags.
<original_code>{old_contents}</original_code>
<update_snippet>{draft_changes}</update_snippet>
"""
CORRECT_SYS_MSG = """You are a code repair assistant. Now you have an original file content and error information from a static code checking tool (lint tool). Your task is to automatically modify and return the repaired complete code based on these error messages and refer to the current file content.
The following are the specific task steps you need to complete:
Carefully read the current file content to ensure that you fully understand its code structure.
According to the lint error prompt, accurately locate and analyze the cause of the problem.
Modify the original file content and fix all errors prompted by the lint tool.
Return complete, runnable, and error-fixed code, paying attention to maintaining the overall style and specifications of the original code.
Please note:
Please strictly follow the lint error prompts to make modifications and do not miss any problems.
The modified code must be complete and cannot introduce new errors or bugs.
The modified code must maintain the original code function and logic, and no changes unrelated to error repair should be made."""
CORRECT_USER_MSG = """
THE FOLLOWING ARE THE ORIGINAL FILE CONTENTS AND THE ERROR INFORMATION REPORTED BY THE LINT TOOL
# CURRENT FILE CONTENT:
```
{file_content}
```
# ERROR MESSAGE FROM STATIC CODE CHECKING TOOL:
```
{lint_error}
```
""".strip()
def _extract_code(string: str) -> str | None:
pattern = r'<updated_code>(.*?)</updated_code>'
matches = re.findall(pattern, string, re.DOTALL)
if not matches:
return None
content = str(matches[0])
if content.startswith('#EDIT:'):
# Remove first line
content = content[content.find('\n') + 1 :]
return content
def get_new_file_contents(
llm: LLM, old_contents: str, draft_changes: str, num_retries: int = 3
) -> str | None:
while num_retries > 0:
messages = [
{
'role': 'user',
'content': USER_MSG.format(
old_contents=old_contents, draft_changes=draft_changes
),
},
]
resp = llm.completion(messages=messages)
new_contents = _extract_code(resp['choices'][0]['message']['content'])
if new_contents is not None:
return new_contents
num_retries -= 1
return None
class FileEditRuntimeInterface(ABC):
config: OpenHandsConfig
@abstractmethod
def read(self, action: FileReadAction) -> Observation:
pass
@abstractmethod
def write(self, action: FileWriteAction) -> Observation:
pass
@abstractmethod
def run_ipython(self, action: IPythonRunCellAction) -> Observation:
pass
class FileEditRuntimeMixin(FileEditRuntimeInterface):
# Most LLMs have output token limit of 4k tokens.
# This restricts the number of lines we can edit to avoid exceeding the token limit.
MAX_LINES_TO_EDIT = 300
def __init__(
self,
enable_llm_editor: bool,
llm_registry: LLMRegistry,
*args: Any,
**kwargs: Any,
) -> None:
super().__init__(*args, **kwargs)
self.enable_llm_editor = enable_llm_editor
if not self.enable_llm_editor:
return
draft_editor_config = self.config.get_llm_config('draft_editor')
# manually set the model name for the draft editor LLM to distinguish token costs
if draft_editor_config.caching_prompt:
logger.debug(
'It is not recommended to cache draft editor LLM prompts as it may incur high costs for the same prompt. '
'Automatically setting caching_prompt=false.'
)
draft_editor_config.caching_prompt = False
self.draft_editor_llm = llm_registry.get_llm(
'draft_editor_llm', draft_editor_config
)
logger.debug(
f'[Draft edit functionality] enabled with LLM: {self.draft_editor_llm}'
)
def _validate_range(
self, start: int, end: int, total_lines: int
) -> Observation | None:
# start and end are 1-indexed and inclusive
if (
(start < 1 and start != -1)
or start > total_lines
or (start > end and end != -1 and start != -1)
):
return ErrorObservation(
f'Invalid range for editing: start={start}, end={end}, total lines={total_lines}. start must be >= 1 and <={total_lines} (total lines of the edited file), start <= end, or start == -1 (append to the end of the file).'
)
if (
(end < 1 and end != -1)
or end > total_lines
or (end < start and start != -1 and end != -1)
):
return ErrorObservation(
f'Invalid range for editing: start={start}, end={end}, total lines={total_lines}. end must be >= 1 and <= {total_lines} (total lines of the edited file), end >= start, or end == -1 (to edit till the end of the file).'
)
return None
def llm_based_edit(self, action: FileEditAction, retry_num: int = 0) -> Observation:
obs = self.read(FileReadAction(path=action.path))
if (
isinstance(obs, ErrorObservation)
and 'File not found'.lower() in obs.content.lower()
):
logger.debug(
f'Agent attempted to edit a file that does not exist. Creating the file. Error msg: {obs.content}'
)
# directly write the new content
obs = self.write(
FileWriteAction(path=action.path, content=action.content.strip())
)
if isinstance(obs, ErrorObservation):
return obs
if not isinstance(obs, FileWriteObservation):
raise ValueError(
f'Expected FileWriteObservation, got {type(obs)}: {str(obs)}'
)
return FileEditObservation(
content=get_diff('', action.content, action.path),
path=action.path,
prev_exist=False,
old_content='',
new_content=action.content,
)
if not isinstance(obs, FileReadObservation):
raise ValueError(
f'Expected FileReadObservation, got {type(obs)}: {str(obs)}'
)
original_file_content = obs.content
old_file_lines = original_file_content.split('\n')
# NOTE: start and end are 1-indexed
start = action.start
end = action.end
# validate the range
error = self._validate_range(start, end, len(old_file_lines))
if error is not None:
return error
# append to the end of the file
if start == -1:
updated_content = '\n'.join(old_file_lines + action.content.split('\n'))
diff = get_diff(original_file_content, updated_content, action.path)
obs = self.write(FileWriteAction(path=action.path, content=updated_content))
return FileEditObservation(
content=diff,
path=action.path,
prev_exist=True,
old_content=original_file_content,
new_content=updated_content,
)
# Get the 0-indexed start and end
start_idx = start - 1
if end != -1:
# remove 1 to make it 0-indexed
# then add 1 since the `end` is inclusive
end_idx = end - 1 + 1
else:
# end == -1 means the user wants to edit till the end of the file
end_idx = len(old_file_lines)
# Get the range of lines to edit - reject if too long
length_of_range = end_idx - start_idx
if length_of_range > self.MAX_LINES_TO_EDIT + 1:
error_msg = (
f'[Edit error: The range of lines to edit is too long.]\n'
f'[The maximum number of lines allowed to edit at once is {self.MAX_LINES_TO_EDIT}. '
f'Got (L{start_idx + 1}-L{end_idx}) {length_of_range} lines.]\n'
# [start_idx, end_idx), so no need to + 1
)
# search for relevant ranges to hint the agent
topk_chunks: list[Chunk] = get_top_k_chunk_matches(
text=original_file_content,
query=action.content, # edit draft as query
k=3,
max_chunk_size=20, # lines
)
error_msg += (
'Here are some snippets that maybe relevant to the provided edit.\n'
)
for i, chunk in enumerate(topk_chunks):
error_msg += f'[begin relevant snippet {i + 1}. Line range: L{chunk.line_range[0]}-L{chunk.line_range[1]}. Similarity: {chunk.normalized_lcs}]\n'
error_msg += f'[Browse around it via `open_file("{action.path}", {(chunk.line_range[0] + chunk.line_range[1]) // 2})`]\n'
error_msg += chunk.visualize() + '\n'
error_msg += f'[end relevant snippet {i + 1}]\n'
error_msg += '-' * 40 + '\n'
error_msg += 'Consider using `open_file` to explore around the relevant snippets if needed.\n'
error_msg += f'**IMPORTANT**: Please REDUCE the range of edits to less than {self.MAX_LINES_TO_EDIT} lines by setting `start` and `end` in the edit action (e.g. `<file_edit path="{action.path}" start=[PUT LINE NUMBER HERE] end=[PUT LINE NUMBER HERE] />`). '
return ErrorObservation(error_msg)
content_to_edit = '\n'.join(old_file_lines[start_idx:end_idx])
_edited_content = get_new_file_contents(
self.draft_editor_llm, content_to_edit, action.content
)
if _edited_content is None:
ret_err = ErrorObservation(
'Failed to get new file contents. '
'Please try to reduce the number of edits and try again.'
)
ret_err.llm_metrics = self.draft_editor_llm.metrics
return ret_err
# piece the updated content with the unchanged content
updated_lines = (
old_file_lines[:start_idx]
+ _edited_content.split('\n')
+ old_file_lines[end_idx:]
)
updated_content = '\n'.join(updated_lines)
diff = get_diff(original_file_content, updated_content, action.path)
obs = self.write(FileWriteAction(path=action.path, content=updated_content))
ret_obs = FileEditObservation(
content=diff,
path=action.path,
prev_exist=True,
old_content=original_file_content,
new_content=updated_content,
)
ret_obs.llm_metrics = self.draft_editor_llm.metrics
return ret_obs
def check_retry_num(self, retry_num: int) -> bool:
correct_num = self.draft_editor_llm.config.correct_num
return correct_num < retry_num
def correct_edit(
self, file_content: str, error_obs: ErrorObservation, retry_num: int = 0
) -> Observation:
return error_obs
+150
View File
@@ -0,0 +1,150 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
"""Utility module for generating file viewer HTML content."""
import base64
import mimetypes
import os
def generate_file_viewer_html(file_path: str) -> str:
"""Generate HTML content for viewing different file types.
Args:
file_path: The absolute path to the file
Returns:
str: HTML content for viewing the file
Raises:
ValueError: If the file extension is not supported
"""
file_extension = os.path.splitext(file_path)[1].lower()
file_name = os.path.basename(file_path)
# Define supported file extensions
supported_extensions = [
'.pdf',
'.png',
'.jpg',
'.jpeg',
'.gif',
]
# Check if the file extension is supported
if file_extension not in supported_extensions:
raise ValueError(
f'Unsupported file extension: {file_extension}. '
f'Supported extensions are: {", ".join(supported_extensions)}'
)
# Check if the file exists
if not os.path.exists(file_path):
raise ValueError(
f'File not found locally: {file_path}. Please download the file to the local machine and try again.'
)
# Read file content directly
file_content = None
mime_type = mimetypes.guess_type(file_path)[0] or 'application/octet-stream'
# For binary files (images, PDFs), encode as base64
if file_extension in ['.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp']:
with open(file_path, 'rb') as file:
file_content = base64.b64encode(file.read()).decode('utf-8')
# For text files, read as text
else:
with open(file_path, 'r', encoding='utf-8') as file:
file_content = file.read()
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>File Viewer - {file_name}</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<style>
body, html {{ margin: 0; padding: 0; height: 100%; overflow: hidden; font-family: Arial, sans-serif; }}
#viewer-container {{ width: 100%; height: 100vh; overflow: auto; }}
.page {{ margin: 10px auto; box-shadow: 0 0 10px rgba(0,0,0,0.3); }}
.text-content {{ margin: 20px; white-space: pre-wrap; font-family: monospace; line-height: 1.5; }}
.error {{ color: red; margin: 20px; }}
img {{ max-width: 100%; margin: 20px auto; display: block; }}
</style>
</head>
<body>
<div id="viewer-container"></div>
<script>
const filePath = "{file_path}";
const fileExtension = "{file_extension}";
const fileContent = `{file_content if file_extension not in ['.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp'] else ''}`;
const fileBase64 = "{file_content if file_extension in ['.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp'] else ''}";
const mimeType = "{mime_type}";
const container = document.getElementById('viewer-container');
async function loadContent() {{
try {{
if (fileExtension === '.pdf') {{
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
const binaryString = atob(fileBase64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {{
bytes[i] = binaryString.charCodeAt(i);
}}
const loadingTask = pdfjsLib.getDocument({{data: bytes.buffer}});
const pdf = await loadingTask.promise;
// Get total number of pages
const numPages = pdf.numPages;
// Render each page
for (let pageNum = 1; pageNum <= numPages; pageNum++) {{
const page = await pdf.getPage(pageNum);
// Set scale for rendering
const viewport = page.getViewport({{ scale: 1.5 }});
// Create canvas for rendering
const canvas = document.createElement('canvas');
canvas.className = 'page';
canvas.width = viewport.width;
canvas.height = viewport.height;
container.appendChild(canvas);
// Render PDF page into canvas context
const context = canvas.getContext('2d');
const renderContext = {{
canvasContext: context,
viewport: viewport
}};
await page.render(renderContext).promise;
}}
}} else if (['.png', '.jpg', '.jpeg', '.gif', '.bmp'].includes(fileExtension)) {{
const img = document.createElement('img');
img.src = `data:${{mimeType}};base64,${{fileBase64}}`;
img.alt = filePath.split('/').pop();
container.appendChild(img);
}} else {{
const pre = document.createElement('pre');
pre.className = 'text-content';
pre.textContent = fileContent;
container.appendChild(pre);
}}
}} catch (error) {{
console.error('Error:', error);
container.innerHTML = `<div class="error"><h2>Error loading file</h2><p>${{error.message}}</p></div>`;
}}
}}
window.onload = loadContent;
</script>
</body>
</html>"""
+157
View File
@@ -0,0 +1,157 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
import os
from pathlib import Path
from openhands.events.observation import (
ErrorObservation,
FileReadObservation,
FileWriteObservation,
Observation,
)
def resolve_path(
file_path: str,
working_directory: str,
workspace_base: str,
workspace_mount_path_in_sandbox: str,
) -> Path:
"""Resolve a file path to a path on the host filesystem.
Args:
file_path: The path to resolve.
working_directory: The working directory of the agent.
workspace_mount_path_in_sandbox: The path to the workspace inside the sandbox.
workspace_base: The base path of the workspace on the host filesystem.
Returns:
The resolved path on the host filesystem.
"""
path_in_sandbox = Path(file_path)
# Apply working directory
if not path_in_sandbox.is_absolute():
path_in_sandbox = Path(working_directory) / path_in_sandbox
# Sanitize the path with respect to the root of the full sandbox
# (deny any .. path traversal to parent directories of the sandbox)
abs_path_in_sandbox = path_in_sandbox.resolve()
# If the path is outside the workspace, deny it
if not abs_path_in_sandbox.is_relative_to(workspace_mount_path_in_sandbox):
raise PermissionError(f'File access not permitted: {file_path}')
# Get path relative to the root of the workspace inside the sandbox
path_in_workspace = abs_path_in_sandbox.relative_to(
Path(workspace_mount_path_in_sandbox)
)
# Get path relative to host
path_in_host_workspace = Path(workspace_base) / path_in_workspace
return path_in_host_workspace
def read_lines(all_lines: list[str], start: int = 0, end: int = -1) -> list[str]:
start = max(start, 0)
start = min(start, len(all_lines))
end = -1 if end == -1 else max(end, 0)
end = min(end, len(all_lines))
if end == -1:
if start == 0:
return all_lines
else:
return all_lines[start:]
else:
num_lines = len(all_lines)
begin = max(0, min(start, num_lines - 2))
end = -1 if end > num_lines else max(begin + 1, end)
return all_lines[begin:end]
async def read_file(
path: str,
workdir: str,
workspace_base: str,
workspace_mount_path_in_sandbox: str,
start: int = 0,
end: int = -1,
) -> Observation:
try:
whole_path = resolve_path(
path, workdir, workspace_base, workspace_mount_path_in_sandbox
)
except PermissionError:
return ErrorObservation(
f"You're not allowed to access this path: {path}. You can only access paths inside the workspace."
)
try:
with open(whole_path, 'r', encoding='utf-8') as file:
lines = read_lines(file.readlines(), start, end)
except FileNotFoundError:
return ErrorObservation(f'File not found: {path}')
except UnicodeDecodeError:
return ErrorObservation(f'File could not be decoded as utf-8: {path}')
except IsADirectoryError:
return ErrorObservation(f'Path is a directory: {path}. You can only read files')
code_view = ''.join(lines)
return FileReadObservation(path=path, content=code_view)
def insert_lines(
to_insert: list[str], original: list[str], start: int = 0, end: int = -1
) -> list[str]:
"""Insert the new content to the original content based on start and end"""
new_lines = [''] if start == 0 else original[:start]
new_lines += [i + '\n' for i in to_insert]
new_lines += [''] if end == -1 else original[end:]
return new_lines
async def write_file(
path: str,
workdir: str,
workspace_base: str,
workspace_mount_path_in_sandbox: str,
content: str,
start: int = 0,
end: int = -1,
) -> Observation:
insert = content.split('\n')
try:
whole_path = resolve_path(
path, workdir, workspace_base, workspace_mount_path_in_sandbox
)
if not os.path.exists(os.path.dirname(whole_path)):
os.makedirs(os.path.dirname(whole_path))
mode = 'w' if not os.path.exists(whole_path) else 'r+'
try:
with open(whole_path, mode, encoding='utf-8') as file:
if mode != 'w':
all_lines = file.readlines()
new_file = insert_lines(insert, all_lines, start, end)
else:
new_file = [i + '\n' for i in insert]
file.seek(0)
file.writelines(new_file)
file.truncate()
except FileNotFoundError:
return ErrorObservation(f'File not found: {path}')
except IsADirectoryError:
return ErrorObservation(
f'Path is a directory: {path}. You can only write to files'
)
except UnicodeDecodeError:
return ErrorObservation(f'File could not be decoded as utf-8: {path}')
except PermissionError as e:
return ErrorObservation(f'Permission error on {path}: {e}')
return FileWriteObservation(content='', path=path)
+200
View File
@@ -0,0 +1,200 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
#!/usr/bin/env python3
"""Get git changes in the current working directory relative to the remote origin if possible.
NOTE: Since this is run as a script, there should be no imports from project files!
"""
import glob
import json
import os
import subprocess
from pathlib import Path
def run(cmd: str, cwd: str) -> str:
result = subprocess.run(
args=cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd
)
byte_content = result.stderr or result.stdout or b''
if result.returncode != 0:
raise RuntimeError(
f'error_running_cmd:{result.returncode}:{byte_content.decode()}'
)
return byte_content.decode().strip()
def get_valid_ref(repo_dir: str) -> str | None:
refs = []
try:
current_branch = run('git --no-pager rev-parse --abbrev-ref HEAD', repo_dir)
refs.append(f'origin/{current_branch}')
except RuntimeError:
pass
try:
default_branch = (
run('git --no-pager remote show origin | grep "HEAD branch"', repo_dir)
.split()[-1]
.strip()
)
ref_non_default_branch = f'$(git --no-pager merge-base HEAD "$(git --no-pager rev-parse --abbrev-ref origin/{default_branch})")'
ref_default_branch = f'origin/{default_branch}'
refs.append(ref_non_default_branch)
refs.append(ref_default_branch)
except RuntimeError:
pass
# compares with empty tree
ref_new_repo = (
'$(git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)'
)
refs.append(ref_new_repo)
# Find a ref that exists...
for ref in refs:
try:
result = run(f'git --no-pager rev-parse --verify {ref}', repo_dir)
return result
except RuntimeError:
# invalid ref - try next
continue
return None
def get_changes_in_repo(repo_dir: str) -> list[dict[str, str]]:
# Gets the status relative to the origin default branch - not the same as `git status`
ref = get_valid_ref(repo_dir)
if not ref:
return []
# Get changed files
changed_files = run(
f'git --no-pager diff --name-status {ref}', repo_dir
).splitlines()
changes = []
for line in changed_files:
if not line.strip():
raise RuntimeError(f'unexpected_value_in_git_diff:{changed_files}')
# Handle different output formats from git diff --name-status
# Depending on git config, format can be either:
# * "A file.txt"
# * "A file.txt"
# * "R100 old_file.txt new_file.txt" (rename with similarity percentage)
parts = line.split()
if len(parts) < 2:
raise RuntimeError(f'unexpected_value_in_git_diff:{changed_files}')
status = parts[0].strip()
# Handle rename operations (status starts with 'R' followed by similarity percentage)
if status.startswith('R') and len(parts) == 3:
# Rename: convert to delete (old path) + add (new path)
old_path = parts[1].strip()
new_path = parts[2].strip()
changes.append(
{
'status': 'D',
'path': old_path,
}
)
changes.append(
{
'status': 'A',
'path': new_path,
}
)
continue
# Handle copy operations (status starts with 'C' followed by similarity percentage)
elif status.startswith('C') and len(parts) == 3:
# Copy: only add the new path (original remains)
new_path = parts[2].strip()
changes.append(
{
'status': 'A',
'path': new_path,
}
)
continue
# Handle regular operations (M, A, D, etc.)
elif len(parts) == 2:
path = parts[1].strip()
else:
raise RuntimeError(f'unexpected_value_in_git_diff:{changed_files}')
if status == '??':
status = 'A'
elif status == '*':
status = 'M'
# Check for valid single-character status codes
if status in {'M', 'A', 'D', 'U'}:
changes.append(
{
'status': status,
'path': path,
}
)
else:
raise RuntimeError(f'unexpected_status_in_git_diff:{changed_files}')
# Get untracked files
untracked_files = run(
'git --no-pager ls-files --others --exclude-standard', repo_dir
).splitlines()
for path in untracked_files:
if path:
changes.append({'status': 'A', 'path': path})
return changes
def get_git_changes(cwd: str) -> list[dict[str, str]]:
git_dirs = {
os.path.dirname(f)[2:]
for f in glob.glob('./*/.git', root_dir=cwd, recursive=True)
}
# First try the workspace directory
changes = get_changes_in_repo(cwd)
# Filter out any changes which are in one of the git directories
changes = [
change
for change in changes
if next(
iter(git_dir for git_dir in git_dirs if change['path'].startswith(git_dir)),
None,
)
is None
]
# Add changes from git directories
for git_dir in git_dirs:
git_dir_changes = get_changes_in_repo(str(Path(cwd, git_dir)))
for change in git_dir_changes:
change['path'] = git_dir + '/' + change['path']
changes.append(change)
changes.sort(key=lambda change: change['path'])
return changes
if __name__ == '__main__':
try:
changes = get_git_changes(os.getcwd())
print(json.dumps(changes))
except Exception as e:
print(json.dumps({'error': str(e)}))
+122
View File
@@ -0,0 +1,122 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
#!/usr/bin/env python3
"""Get git diff in a single git file for the closest git repo in the file system
NOTE: Since this is run as a script, there should be no imports from project files!
"""
import json
import os
import shlex
import subprocess
import sys
from pathlib import Path
MAX_FILE_SIZE_FOR_GIT_DIFF = 1024 * 1024 # 1 Mb
def get_closest_git_repo(path: Path) -> Path | None:
while True:
path = path.parent
git_path = Path(path, '.git')
if git_path.is_dir():
return path
if path.parent == path:
return None
def run(cmd: str, cwd: str) -> str:
result = subprocess.run(
args=cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd
)
byte_content = result.stderr or result.stdout or b''
if result.returncode != 0:
raise RuntimeError(
f'error_running_cmd:{result.returncode}:{byte_content.decode()}'
)
return byte_content.decode().strip()
def get_valid_ref(repo_dir: str) -> str | None:
refs = []
try:
current_branch = run('git --no-pager rev-parse --abbrev-ref HEAD', repo_dir)
refs.append(f'origin/{current_branch}')
except RuntimeError:
pass
try:
default_branch = (
run('git --no-pager remote show origin | grep "HEAD branch"', repo_dir)
.split()[-1]
.strip()
)
ref_non_default_branch = f'$(git --no-pager merge-base HEAD "$(git --no-pager rev-parse --abbrev-ref origin/{default_branch})")'
ref_default_branch = f'origin/{default_branch}'
refs.append(ref_non_default_branch)
refs.append(ref_default_branch)
except RuntimeError:
pass
# compares with empty tree
ref_new_repo = (
'$(git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)'
)
refs.append(ref_new_repo)
# Find a ref that exists...
for ref in refs:
try:
result = run(f'git --no-pager rev-parse --verify {ref}', repo_dir)
return result
except RuntimeError:
# invalid ref - try next
continue
return None
def _make_git_show_cmd(ref: str, repo_relative_path: str) -> str:
"""Return a git-show shell command with the ref:path argument safely quoted."""
return f'git show {shlex.quote(f"{ref}:{repo_relative_path}")}'
def get_git_diff(relative_file_path: str) -> dict[str, str]:
path = Path(os.getcwd(), relative_file_path).resolve()
if os.path.getsize(path) > MAX_FILE_SIZE_FOR_GIT_DIFF:
raise ValueError('file_to_large')
closest_git_repo = get_closest_git_repo(path)
if not closest_git_repo:
raise ValueError('no_repository')
current_rev = get_valid_ref(str(closest_git_repo))
original = ''
if current_rev is not None:
try:
original = run(
_make_git_show_cmd(
current_rev, str(path.relative_to(closest_git_repo))
),
str(closest_git_repo),
)
except RuntimeError:
pass
try:
with open(path, 'r') as f:
modified = '\n'.join(f.read().splitlines())
except FileNotFoundError:
modified = ''
return {
'modified': modified,
'original': original,
}
if __name__ == '__main__':
diff = get_git_diff(sys.argv[-1])
print(json.dumps(diff))
+157
View File
@@ -0,0 +1,157 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
import json
import shlex
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.utils import git_changes, git_diff
GIT_CHANGES_CMD = 'python3 /openhands/code/openhands/runtime/utils/git_changes.py'
GIT_DIFF_CMD = 'python3 /openhands/code/openhands/runtime/utils/git_diff.py {file_path}'
GIT_BRANCH_CMD = 'git branch --show-current'
@dataclass
class CommandResult:
"""Represents the result of a shell command execution.
Attributes:
content (str): The output content of the command.
exit_code (int): The exit code of the command execution.
"""
content: str
exit_code: int
class GitHandler:
"""A handler for executing Git-related operations via shell commands."""
def __init__(
self,
execute_shell_fn: Callable[[str, str | None], CommandResult],
create_file_fn: Callable[[str, str], int],
):
self.execute = execute_shell_fn
self.create_file_fn = create_file_fn
self.cwd: str | None = None
self.git_changes_cmd = GIT_CHANGES_CMD
self.git_diff_cmd = GIT_DIFF_CMD
self.git_branch_cmd = GIT_BRANCH_CMD
def set_cwd(self, cwd: str) -> None:
"""Sets the current working directory for Git operations.
Args:
cwd (str): The directory path.
"""
self.cwd = cwd
def _create_python_script_file(self, file: str):
result = self.execute('mktemp -d', self.cwd)
script_file = Path(result.content.strip(), Path(file).name)
with open(file, 'r') as f:
self.create_file_fn(str(script_file), f.read())
result = self.execute(f'chmod +x "{script_file}"', self.cwd)
return script_file
def get_current_branch(self) -> str | None:
"""
Retrieves the current branch name of the git repository.
Returns:
str | None: The current branch name, or None if not a git repository or error occurs.
"""
# If cwd is not set, return None
if not self.cwd:
return None
result = self.execute(self.git_branch_cmd, self.cwd)
if result.exit_code == 0:
branch = result.content.strip()
# git branch --show-current returns empty string if not on any branch (detached HEAD)
if branch:
return branch
return None
# If not a git repository or other error, return None
return None
def get_git_changes(self) -> list[dict[str, str]] | None:
"""Retrieves the list of changed files in Git repositories.
Examines each direct subdirectory of the workspace directory looking for git repositories
and returns the changes for each of these directories.
Optimized to use a single git command per repository for maximum performance.
Returns:
list[dict[str, str]] | None: A list of dictionaries containing file paths and statuses. None if no git repositories found.
"""
# If cwd is not set, return None
if not self.cwd:
return None
result = self.execute(self.git_changes_cmd, self.cwd)
if result.exit_code == 0:
try:
changes = json.loads(result.content)
return changes
except Exception:
logger.exception(
'GitHandler:get_git_changes:error',
extra={'content': result.content},
)
return None
if self.git_changes_cmd != GIT_CHANGES_CMD:
# We have already tried to add a script to the workspace - it did not work
return None
# We try to add a script for getting git changes to the runtime - legacy runtimes may be missing the script
logger.info(
'GitHandler:get_git_changes: adding git_changes script to runtime...'
)
script_file = self._create_python_script_file(git_changes.__file__)
self.git_changes_cmd = f'python3 {script_file}'
# Try again with the new changes cmd
return self.get_git_changes()
def get_git_diff(self, file_path: str) -> dict[str, str]:
"""Retrieves the original and modified content of a file in the repository.
Args:
file_path (str): Path to the file.
Returns:
dict[str, str]: A dictionary containing the original and modified content.
"""
# If cwd is not set, return None
if not self.cwd:
raise ValueError('no_dir_in_git_diff')
result = self.execute(
self.git_diff_cmd.format(file_path=shlex.quote(file_path)), self.cwd
)
if result.exit_code == 0:
diff = json.loads(result.content, strict=False)
return diff
if self.git_diff_cmd != GIT_DIFF_CMD:
# We have already tried to add a script to the workspace - it did not work
raise ValueError('error_in_git_diff')
# We try to add a script for getting git changes to the runtime - legacy runtimes may be missing the script
logger.info('GitHandler:get_git_diff: adding git_diff script to runtime...')
script_file = self._create_python_script_file(git_diff.__file__)
self.git_diff_cmd = f'python3 {script_file} {{file_path}}'
# Try again with the new changes cmd
return self.get_git_diff(file_path)
+72
View File
@@ -0,0 +1,72 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
import threading
from typing import Callable
import docker
class LogStreamer:
"""Streams Docker container logs to stdout.
This class provides a way to stream logs from a Docker container directly to stdout
through the provided logging function.
"""
def __init__(
self,
container: docker.models.containers.Container,
logFn: Callable[[str, str], None],
):
self.log = logFn
# Initialize all attributes before starting the thread on this instance
self.stdout_thread = None
self.log_generator = None
self._stop_event = threading.Event()
try:
self.log_generator = container.logs(stream=True, follow=True)
# Start the stdout streaming thread
self.stdout_thread = threading.Thread(target=self._stream_logs)
self.stdout_thread.daemon = True
self.stdout_thread.start()
except Exception as e:
self.log('error', f'Failed to initialize log streaming: {e}')
def _stream_logs(self) -> None:
"""Stream logs from the Docker container to stdout."""
if not self.log_generator:
self.log('error', 'Log generator not initialized')
return
try:
for log_line in self.log_generator:
if self._stop_event.is_set():
break
if log_line:
decoded_line = log_line.decode('utf-8').rstrip()
self.log('debug', f'[inside container] {decoded_line}')
except Exception as e:
self.log('error', f'Error streaming docker logs to stdout: {e}')
def __del__(self) -> None:
if (
hasattr(self, 'stdout_thread')
and self.stdout_thread
and self.stdout_thread.is_alive()
):
self.close(timeout=5)
def close(self, timeout: float = 5.0) -> None:
"""Clean shutdown of the log streaming."""
self._stop_event.set()
if self.stdout_thread and self.stdout_thread.is_alive():
self.stdout_thread.join(timeout)
# Close the log generator to release the file descriptor
if self.log_generator is not None:
self.log_generator.close()
+73
View File
@@ -0,0 +1,73 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
"""Memory monitoring utilities for the runtime."""
import threading
from memory_profiler import memory_usage
from openhands.core.logger import openhands_logger as logger
class LogStream:
"""Stream-like object that redirects writes to a logger."""
def write(self, message: str) -> None:
if message and not message.isspace():
logger.info(f'[Memory usage] {message.strip()}')
def flush(self) -> None:
pass
class MemoryMonitor:
def __init__(self, enable: bool = False):
"""Memory monitor for the runtime."""
self._monitoring_thread: threading.Thread | None = None
self._stop_monitoring = threading.Event()
self.log_stream = LogStream()
self.enable = enable
def start_monitoring(self) -> None:
"""Start monitoring memory usage."""
if not self.enable:
return
if self._monitoring_thread is not None:
return
def monitor_process() -> None:
try:
# Use memory_usage's built-in monitoring loop
mem_usage = memory_usage(
-1, # Monitor current process
interval=0.1, # Check every second
timeout=3600, # Run indefinitely
max_usage=False, # Get continuous readings
include_children=True, # Include child processes
multiprocess=True, # Monitor all processes
stream=self.log_stream, # Redirect output to logger
backend='psutil_pss',
)
logger.info(f'Memory usage across time: {mem_usage}')
except Exception as e:
logger.error(f'Memory monitoring failed: {e}')
self._monitoring_thread = threading.Thread(target=monitor_process, daemon=True)
self._monitoring_thread.start()
logger.info('Memory monitoring started')
def stop_monitoring(self) -> None:
"""Stop monitoring memory usage."""
if not self.enable:
return
if self._monitoring_thread is not None:
self._stop_monitoring.set()
self._monitoring_thread = None
logger.info('Memory monitoring stopped')
+234
View File
@@ -0,0 +1,234 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
"""File-based port locking system for preventing race conditions in port allocation."""
import os
import random
import socket
import tempfile
import time
from typing import Optional
from openhands.core.logger import openhands_logger as logger
# Import fcntl only on Unix systems
try:
import fcntl
HAS_FCNTL = True
except ImportError:
HAS_FCNTL = False
class PortLock:
"""File-based lock for a specific port to prevent race conditions."""
def __init__(self, port: int, lock_dir: Optional[str] = None):
self.port = port
self.lock_dir = lock_dir or os.path.join(
tempfile.gettempdir(), 'openhands_port_locks'
)
self.lock_file_path = os.path.join(self.lock_dir, f'port_{port}.lock')
self.lock_fd: Optional[int] = None
self._locked = False
# Ensure lock directory exists
os.makedirs(self.lock_dir, exist_ok=True)
def acquire(self, timeout: float = 1.0) -> bool:
"""Acquire the lock for this port.
Args:
timeout: Maximum time to wait for the lock
Returns:
True if lock was acquired, False otherwise
"""
if self._locked:
return True
try:
if HAS_FCNTL:
# Unix-style file locking with fcntl
self.lock_fd = os.open(
self.lock_file_path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC
)
# Try to acquire exclusive lock with timeout
start_time = time.time()
while time.time() - start_time < timeout:
try:
fcntl.flock(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
self._locked = True
# Write port number to lock file for debugging
os.write(self.lock_fd, f'{self.port}\n'.encode())
os.fsync(self.lock_fd)
logger.debug(f'Acquired lock for port {self.port}')
return True
except (OSError, IOError):
# Lock is held by another process, wait a bit
time.sleep(0.01)
# Timeout reached
if self.lock_fd:
os.close(self.lock_fd)
self.lock_fd = None
return False
else:
# Windows fallback: use atomic file creation
start_time = time.time()
while time.time() - start_time < timeout:
try:
# Try to create lock file exclusively
self.lock_fd = os.open(
self.lock_file_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY
)
self._locked = True
# Write port number to lock file for debugging
os.write(self.lock_fd, f'{self.port}\n'.encode())
os.fsync(self.lock_fd)
logger.debug(f'Acquired lock for port {self.port}')
return True
except OSError:
# Lock file already exists, wait a bit
time.sleep(0.01)
# Timeout reached
return False
except Exception as e:
logger.debug(f'Failed to acquire lock for port {self.port}: {e}')
if self.lock_fd:
try:
os.close(self.lock_fd)
except OSError:
pass
self.lock_fd = None
return False
def release(self) -> None:
"""Release the lock."""
if self.lock_fd is not None:
try:
if HAS_FCNTL:
# Unix: unlock and close
fcntl.flock(self.lock_fd, fcntl.LOCK_UN)
os.close(self.lock_fd)
# Remove lock file (both Unix and Windows)
try:
os.unlink(self.lock_file_path)
except FileNotFoundError:
pass
logger.debug(f'Released lock for port {self.port}')
except Exception as e:
logger.warning(f'Error releasing lock for port {self.port}: {e}')
finally:
self.lock_fd = None
self._locked = False
def __enter__(self) -> 'PortLock':
if not self.acquire():
raise OSError(f'Could not acquire lock for port {self.port}')
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
self.release()
@property
def is_locked(self) -> bool:
return self._locked
def find_available_port_with_lock(
min_port: int = 30000,
max_port: int = 39999,
max_attempts: int = 20,
bind_address: str = '0.0.0.0',
lock_timeout: float = 1.0,
) -> Optional[tuple[int, PortLock]]:
"""Find an available port and acquire a lock for it.
This function combines file-based locking with port availability checking
to prevent race conditions in multi-process scenarios.
Args:
min_port: Minimum port number to try
max_port: Maximum port number to try
max_attempts: Maximum number of ports to try
bind_address: Address to bind to when checking availability
lock_timeout: Timeout for acquiring port lock
Returns:
Tuple of (port, lock) if successful, None otherwise
"""
rng = random.SystemRandom()
# Try random ports first for better distribution
random_attempts = min(max_attempts // 2, 10)
for _ in range(random_attempts):
port = rng.randint(min_port, max_port)
# Try to acquire lock first
lock = PortLock(port)
if lock.acquire(timeout=lock_timeout):
# Check if port is actually available
if _check_port_available(port, bind_address):
logger.debug(f'Found and locked available port {port}')
return port, lock
else:
# Port is locked but not available (maybe in TIME_WAIT state)
lock.release()
# Small delay to reduce contention
time.sleep(0.001)
# If random attempts failed, try sequential search
remaining_attempts = max_attempts - random_attempts
start_port = rng.randint(min_port, max_port - remaining_attempts)
for i in range(remaining_attempts):
port = start_port + i
if port > max_port:
port = min_port + (port - max_port - 1)
# Try to acquire lock first
lock = PortLock(port)
if lock.acquire(timeout=lock_timeout):
# Check if port is actually available
if _check_port_available(port, bind_address):
logger.debug(f'Found and locked available port {port}')
return port, lock
else:
# Port is locked but not available
lock.release()
# Small delay to reduce contention
time.sleep(0.001)
logger.error(
f'Could not find and lock available port in range {min_port}-{max_port} after {max_attempts} attempts'
)
return None
def _check_port_available(port: int, bind_address: str = '0.0.0.0') -> bool:
"""Check if a port is available by trying to bind to it."""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((bind_address, port))
sock.close()
return True
except OSError:
return False
+69
View File
@@ -0,0 +1,69 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
import json
from typing import Any
import httpx
from httpx import HTTPStatusError
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
from openhands.utils.http_session import HttpSession
from openhands.utils.tenacity_stop import stop_if_should_exit
class RequestHTTPError(httpx.HTTPStatusError):
"""Exception raised when an error occurs in a request with details."""
def __init__(self, *args: Any, detail: Any = None, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.detail = detail
def __str__(self) -> str:
s = super().__str__()
if self.detail is not None:
s += f'\nDetails: {self.detail}'
return str(s)
def is_retryable_error(exception: Any) -> bool:
return (
isinstance(exception, httpx.HTTPStatusError)
and exception.response.status_code == 429
)
@retry(
retry=retry_if_exception(is_retryable_error),
stop=stop_after_attempt(3) | stop_if_should_exit(),
wait=wait_exponential(multiplier=1, min=4, max=60),
)
def send_request(
session: HttpSession,
method: str,
url: str,
timeout: int = 60,
**kwargs: Any,
) -> httpx.Response:
response = session.request(method, url, timeout=timeout, **kwargs)
try:
response.raise_for_status()
except httpx.HTTPError as e:
try:
_json = response.json()
except json.decoder.JSONDecodeError:
_json = None
finally:
response.close()
raise RequestHTTPError(
e,
request=e.request,
response=e.response if isinstance(e, HTTPStatusError) else None,
detail=_json.get('detail') if _json is not None else None,
) from e
return response
+489
View File
@@ -0,0 +1,489 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
import argparse
import hashlib
import os
import shutil
import string
import tempfile
from enum import Enum
from pathlib import Path
import docker
from dirhash import dirhash
from jinja2 import Environment, FileSystemLoader
import openhands
from openhands.core.exceptions import AgentRuntimeBuildError
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.builder import DockerRuntimeBuilder, RuntimeBuilder
from openhands.version import get_version
class BuildFromImageType(Enum):
SCRATCH = 'scratch' # Slowest: Build from base image (no dependencies are reused)
VERSIONED = 'versioned' # Medium speed: Reuse the most recent image with the same base image & OH version (a lot of dependencies are already installed)
LOCK = 'lock' # Fastest: Reuse the most recent image with the exact SAME dependencies (lock files)
def get_runtime_image_repo() -> str:
return os.getenv('OH_RUNTIME_RUNTIME_IMAGE_REPO', 'ghcr.io/openhands/runtime')
def _generate_dockerfile(
base_image: str,
build_from: BuildFromImageType = BuildFromImageType.SCRATCH,
extra_deps: str | None = None,
enable_browser: bool = True,
) -> str:
"""Generate the Dockerfile content for the runtime image based on the base image.
Parameters:
- base_image (str): The base image provided for the runtime image
- build_from (BuildFromImageType): The build method for the runtime image.
- extra_deps (str):
- enable_browser (bool): Whether to enable browser support (install Playwright)
Returns:
- str: The resulting Dockerfile content
"""
env = Environment(
loader=FileSystemLoader(
searchpath=os.path.join(os.path.dirname(__file__), 'runtime_templates')
)
)
template = env.get_template('Dockerfile.j2')
# Allow overriding conda/mamba channel alias (e.g., to avoid anaconda.org)
channel_alias = os.getenv('OH_CONDA_CHANNEL_ALIAS', '').strip() or None
dockerfile_content = template.render(
base_image=base_image,
build_from_scratch=build_from == BuildFromImageType.SCRATCH,
build_from_versioned=build_from == BuildFromImageType.VERSIONED,
extra_deps=extra_deps if extra_deps is not None else '',
enable_browser=enable_browser,
channel_alias=channel_alias,
)
return dockerfile_content
def get_runtime_image_repo_and_tag(base_image: str) -> tuple[str, str]:
"""Retrieves the Docker repo and tag associated with the Docker image.
Parameters:
- base_image (str): The name of the base Docker image
Returns:
- tuple[str, str]: The Docker repo and tag of the Docker image
"""
if get_runtime_image_repo() in base_image:
logger.debug(
f'The provided image [{base_image}] is already a valid runtime image.\n'
f'Will try to reuse it as is.'
)
if ':' not in base_image:
base_image = base_image + ':latest'
repo, tag = base_image.split(':')
return repo, tag
else:
if ':' not in base_image:
base_image = base_image + ':latest'
[repo, tag] = base_image.split(':')
# Hash the repo if it's too long
if len(repo) > 32:
repo_hash = hashlib.md5(repo[:-24].encode()).hexdigest()[:8]
repo = f'{repo_hash}_{repo[-24:]}' # Use 8 char hash + last 24 chars
repo = repo.replace('/', '_s_')
new_tag = f'oh_v{get_version()}_image_{repo}_tag_{tag}'
# if it's still too long, hash the entire image name
if len(new_tag) > 128:
new_tag = f'oh_v{get_version()}_image_{hashlib.md5(new_tag.encode()).hexdigest()[:64]}'
logger.warning(
f'The new tag [{new_tag}] is still too long, so we use an hash of the entire image name: {new_tag}'
)
return get_runtime_image_repo(), new_tag
def build_runtime_image(
base_image: str,
runtime_builder: RuntimeBuilder,
platform: str | None = None,
extra_deps: str | None = None,
build_folder: str | None = None,
dry_run: bool = False,
force_rebuild: bool = False,
extra_build_args: list[str] | None = None,
enable_browser: bool = True,
) -> str:
"""Prepares the final docker build folder.
If dry_run is False, it will also build the OpenHands runtime Docker image using the docker build folder.
Parameters:
- base_image (str): The name of the base Docker image to use
- runtime_builder (RuntimeBuilder): The runtime builder to use
- platform (str): The target platform for the build (e.g. linux/amd64, linux/arm64)
- extra_deps (str):
- build_folder (str): The directory to use for the build. If not provided a temporary directory will be used
- dry_run (bool): if True, it will only ready the build folder. It will not actually build the Docker image
- force_rebuild (bool): if True, it will create the Dockerfile which uses the base_image
- extra_build_args (List[str]): Additional build arguments to pass to the builder
- enable_browser (bool): Whether to enable browser support (install Playwright)
Returns:
- str: <image_repo>:<MD5 hash>. Where MD5 hash is the hash of the docker build folder
See https://docs.all-hands.dev/usage/architecture/runtime for more details.
"""
if build_folder is None:
with tempfile.TemporaryDirectory() as temp_dir:
result = build_runtime_image_in_folder(
base_image=base_image,
runtime_builder=runtime_builder,
build_folder=Path(temp_dir),
extra_deps=extra_deps,
dry_run=dry_run,
force_rebuild=force_rebuild,
platform=platform,
extra_build_args=extra_build_args,
enable_browser=enable_browser,
)
return result
result = build_runtime_image_in_folder(
base_image=base_image,
runtime_builder=runtime_builder,
build_folder=Path(build_folder),
extra_deps=extra_deps,
dry_run=dry_run,
force_rebuild=force_rebuild,
platform=platform,
extra_build_args=extra_build_args,
enable_browser=enable_browser,
)
return result
def build_runtime_image_in_folder(
base_image: str,
runtime_builder: RuntimeBuilder,
build_folder: Path,
extra_deps: str | None,
dry_run: bool,
force_rebuild: bool,
platform: str | None = None,
extra_build_args: list[str] | None = None,
enable_browser: bool = True,
) -> str:
runtime_image_repo, _ = get_runtime_image_repo_and_tag(base_image)
lock_tag = (
f'oh_v{get_version()}_{get_hash_for_lock_files(base_image, enable_browser)}'
)
versioned_tag = (
# truncate the base image to 96 characters to fit in the tag max length (128 characters)
f'oh_v{get_version()}_{get_tag_for_versioned_image(base_image)}'
)
versioned_image_name = f'{runtime_image_repo}:{versioned_tag}'
source_tag = f'{lock_tag}_{get_hash_for_source_files()}'
hash_image_name = f'{runtime_image_repo}:{source_tag}'
logger.info(f'Building image: {hash_image_name}')
if force_rebuild:
logger.debug(
f'Force rebuild: [{runtime_image_repo}:{source_tag}] from scratch.'
)
prep_build_folder(
build_folder,
base_image,
build_from=BuildFromImageType.SCRATCH,
extra_deps=extra_deps,
enable_browser=enable_browser,
)
if not dry_run:
_build_sandbox_image(
build_folder,
runtime_builder,
runtime_image_repo,
source_tag,
lock_tag,
versioned_tag,
platform,
extra_build_args=extra_build_args,
)
return hash_image_name
lock_image_name = f'{runtime_image_repo}:{lock_tag}'
build_from = BuildFromImageType.SCRATCH
# If the exact image already exists, we do not need to build it
if runtime_builder.image_exists(hash_image_name, False):
logger.debug(f'Reusing Image [{hash_image_name}]')
return hash_image_name
# We look for an existing image that shares the same lock_tag. If such an image exists, we
# can use it as the base image for the build and just copy source files. This makes the build
# much faster.
if runtime_builder.image_exists(lock_image_name):
logger.debug(f'Build [{hash_image_name}] from lock image [{lock_image_name}]')
build_from = BuildFromImageType.LOCK
base_image = lock_image_name
elif runtime_builder.image_exists(versioned_image_name):
logger.info(
f'Build [{hash_image_name}] from versioned image [{versioned_image_name}]'
)
build_from = BuildFromImageType.VERSIONED
base_image = versioned_image_name
else:
logger.debug(f'Build [{hash_image_name}] from scratch')
prep_build_folder(build_folder, base_image, build_from, extra_deps, enable_browser)
if not dry_run:
_build_sandbox_image(
build_folder,
runtime_builder,
runtime_image_repo,
source_tag=source_tag,
lock_tag=lock_tag,
# Only tag the versioned image if we are building from scratch.
# This avoids too much layers when you lay one image on top of another multiple times
versioned_tag=(
versioned_tag if build_from == BuildFromImageType.SCRATCH else None
),
platform=platform,
extra_build_args=extra_build_args,
)
return hash_image_name
def prep_build_folder(
build_folder: Path,
base_image: str,
build_from: BuildFromImageType,
extra_deps: str | None,
enable_browser: bool = True,
) -> None:
# Copy the source code to directory. It will end up in build_folder/code
# If package is not found, build from source code
openhands_source_dir = Path(openhands.__file__).parent
project_root = openhands_source_dir.parent
logger.debug(f'Building source distribution using project root: {project_root}')
# Copy the 'openhands' directory (Source code)
shutil.copytree(
openhands_source_dir,
Path(build_folder, 'code', 'openhands'),
ignore=shutil.ignore_patterns(
'.*/',
'__pycache__/',
'*.pyc',
'*.md',
),
)
# Copy the 'skills' directory (Skills)
shutil.copytree(Path(project_root, 'skills'), Path(build_folder, 'code', 'skills'))
# Copy pyproject.toml and poetry.lock files
for file in ['pyproject.toml', 'poetry.lock']:
src = Path(openhands_source_dir, file)
if not src.exists():
src = Path(project_root, file)
shutil.copy2(src, Path(build_folder, 'code', file))
# Create a Dockerfile and write it to build_folder
dockerfile_content = _generate_dockerfile(
base_image,
build_from=build_from,
extra_deps=extra_deps,
enable_browser=enable_browser,
)
dockerfile_path = Path(build_folder, 'Dockerfile')
with open(str(dockerfile_path), 'w') as f:
f.write(dockerfile_content)
_ALPHABET = string.digits + string.ascii_lowercase
def truncate_hash(hash: str) -> str:
"""Convert the base16 hash to base36 and truncate at 16 characters."""
value = int(hash, 16)
result: list[str] = []
while value > 0 and len(result) < 16:
value, remainder = divmod(value, len(_ALPHABET))
result.append(_ALPHABET[remainder])
return ''.join(result)
def get_hash_for_lock_files(base_image: str, enable_browser: bool = True) -> str:
openhands_source_dir = Path(openhands.__file__).parent
md5 = hashlib.md5()
md5.update(base_image.encode())
# Only include enable_browser in hash when it's False for backward compatibility
if not enable_browser:
md5.update(str(enable_browser).encode())
for file in ['pyproject.toml', 'poetry.lock']:
src = Path(openhands_source_dir, file)
if not src.exists():
src = Path(openhands_source_dir.parent, file)
with open(src, 'rb') as f:
for chunk in iter(lambda: f.read(4096), b''):
md5.update(chunk)
# We get away with truncation because we want something that is unique
# rather than something that is cryptographically secure
result = truncate_hash(md5.hexdigest())
return result
def get_tag_for_versioned_image(base_image: str) -> str:
return base_image.replace('/', '_s_').replace(':', '_t_').lower()[-96:]
def get_hash_for_source_files() -> str:
openhands_source_dir = Path(openhands.__file__).parent
dir_hash = dirhash(
openhands_source_dir,
'md5',
ignore=[
'.*/', # hidden directories
'__pycache__/',
'*.pyc',
],
)
# We get away with truncation because we want something that is unique
# rather than something that is cryptographically secure
result = truncate_hash(dir_hash)
return result
def _build_sandbox_image(
build_folder: Path,
runtime_builder: RuntimeBuilder,
runtime_image_repo: str,
source_tag: str,
lock_tag: str,
versioned_tag: str | None,
platform: str | None = None,
extra_build_args: list[str] | None = None,
) -> str:
"""Build and tag the sandbox image. The image will be tagged with all tags that do not yet exist."""
names = [
f'{runtime_image_repo}:{source_tag}',
f'{runtime_image_repo}:{lock_tag}',
]
if versioned_tag is not None:
names.append(f'{runtime_image_repo}:{versioned_tag}')
names = [name for name in names if not runtime_builder.image_exists(name, False)]
image_name = runtime_builder.build(
path=str(build_folder),
tags=names,
platform=platform,
extra_build_args=extra_build_args,
)
if not image_name:
raise AgentRuntimeBuildError(f'Build failed for image {names}')
return image_name
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument(
'--base_image',
type=str,
default='nikolaik/python-nodejs:python3.12-nodejs22-slim',
)
parser.add_argument('--build_folder', type=str, default=None)
parser.add_argument('--force_rebuild', action='store_true', default=False)
parser.add_argument('--platform', type=str, default=None)
parser.add_argument('--enable_browser', action='store_true', default=True)
parser.add_argument(
'--no_enable_browser', dest='enable_browser', action='store_false'
)
args = parser.parse_args()
if args.build_folder is not None:
# If a build_folder is provided, we do not actually build the Docker image. We copy the necessary source code
# and create a Dockerfile dynamically and place it in the build_folder only. This allows the Docker image to
# then be created using the Dockerfile (most likely using the containers/build.sh script)
build_folder = args.build_folder
assert os.path.exists(build_folder), (
f'Build folder {build_folder} does not exist'
)
logger.debug(
f'Copying the source code and generating the Dockerfile in the build folder: {build_folder}'
)
runtime_image_repo, runtime_image_tag = get_runtime_image_repo_and_tag(
args.base_image
)
logger.debug(
f'Runtime image repo: {runtime_image_repo} and runtime image tag: {runtime_image_tag}'
)
with tempfile.TemporaryDirectory() as temp_dir:
# dry_run is true so we only prepare a temp_dir containing the required source code and the Dockerfile. We
# then obtain the MD5 hash of the folder and return <image_repo>:<temp_dir_md5_hash>
runtime_image_hash_name = build_runtime_image(
args.base_image,
runtime_builder=DockerRuntimeBuilder(docker.from_env()),
build_folder=temp_dir,
dry_run=True,
force_rebuild=args.force_rebuild,
platform=args.platform,
enable_browser=args.enable_browser,
)
_runtime_image_repo, runtime_image_source_tag = (
runtime_image_hash_name.split(':')
)
# Move contents of temp_dir to build_folder
shutil.copytree(temp_dir, build_folder, dirs_exist_ok=True)
logger.debug(
f'Build folder [{build_folder}] is ready: {os.listdir(build_folder)}'
)
# We now update the config.sh in the build_folder to contain the required values. This is used in the
# containers/build.sh script which is called to actually build the Docker image
with open(os.path.join(build_folder, 'config.sh'), 'a') as file:
file.write(
(
f'\n'
f'DOCKER_IMAGE_TAG={runtime_image_tag}\n'
f'DOCKER_IMAGE_SOURCE_TAG={runtime_image_source_tag}\n'
)
)
logger.debug(
f'`config.sh` is updated with the image repo[{runtime_image_repo}] and tags [{runtime_image_tag}, {runtime_image_source_tag}]'
)
logger.debug(
f'Dockerfile, source code and config.sh are ready in {build_folder}'
)
else:
# If a build_folder is not provided, after copying the required source code and dynamically creating the
# Dockerfile, we actually build the Docker image
logger.debug('Building image in a temporary folder')
docker_builder = DockerRuntimeBuilder(docker.from_env())
image_name = build_runtime_image(
args.base_image,
docker_builder,
platform=args.platform,
enable_browser=args.enable_browser,
)
logger.debug(f'\nBuilt image: {image_name}\n')
+134
View File
@@ -0,0 +1,134 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
import os
import subprocess
import sys
from openhands.core.logger import openhands_logger as logger
def init_user_and_working_directory(
username: str, user_id: int, initial_cwd: str
) -> int | None:
"""Create working directory and user if not exists.
It performs the following steps effectively:
* Creates the Working Directory:
- Uses mkdir -p to create the directory.
- Sets ownership to username:group (respects SANDBOX_GROUP_ID if set).
- Adjusts permissions to be readable and writable by group and others.
* User Verification and Creation:
- Checks if the user exists using id -u.
- If the user exists with the correct UID, it skips creation.
- If the UID differs, it logs a warning and return an updated user_id.
- If the user doesn't exist, it proceeds to create the user.
* Sudo Configuration:
- Appends %sudo ALL=(ALL) NOPASSWD:ALL to /etc/sudoers to grant
passwordless sudo access to the sudo group.
- Adds the user to the sudo group with the useradd command, handling
UID conflicts by incrementing the UID if necessary.
Args:
username (str): The username to create.
user_id (int): The user ID to assign to the user.
initial_cwd (str): The initial working directory to create.
Returns:
int | None: The user ID if it was updated, None otherwise.
"""
# If running on Windows, just create the directory and return
if sys.platform == 'win32':
logger.debug('Running on Windows, skipping Unix-specific user setup')
logger.debug(f'Client working directory: {initial_cwd}')
# Create the working directory if it doesn't exist
os.makedirs(initial_cwd, exist_ok=True)
logger.debug(f'Created working directory: {initial_cwd}')
return None
# if username is CURRENT_USER, then we don't need to do anything
# This is specific to the local runtime
if username == os.getenv('USER') and username not in ['root', 'openhands']:
return None
# Skip root since it is already created
existing_user_id = -1
if username != 'root':
# Check if the username already exists
logger.debug(f'Attempting to create user `{username}` with UID {user_id}.')
setup_user = True
try:
result = subprocess.run(
f'id -u {username}', shell=True, check=True, capture_output=True
)
existing_user_id = int(result.stdout.decode().strip())
# The user ID already exists, skip setup
if existing_user_id == user_id:
logger.debug(
f'User `{username}` already has the provided UID {user_id}. Skipping user setup.'
)
else:
logger.warning(
f'User `{username}` already exists with UID {existing_user_id}. Skipping user setup.'
)
setup_user = False
except subprocess.CalledProcessError as e:
# Returncode 1 indicates, that the user does not exist yet
if e.returncode == 1:
logger.debug(
f'User `{username}` does not exist. Proceeding with user creation.'
)
else:
logger.error(
f'Error checking user `{username}`, skipping setup:\n{e}\n'
)
raise
if setup_user:
# Add sudoer
sudoer_line = r"echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers"
output = subprocess.run(sudoer_line, shell=True, capture_output=True)
if output.returncode != 0:
raise RuntimeError(f'Failed to add sudoer: {output.stderr.decode()}')
logger.debug(
f'Added sudoer successfully. Output: [{output.stdout.decode()}]'
)
command = (
f'useradd -rm -d /home/{username} -s /bin/bash '
f'-g root -G sudo -u {user_id} {username}'
)
output = subprocess.run(command, shell=True, capture_output=True)
if output.returncode == 0:
logger.debug(
f'Added user `{username}` successfully with UID {user_id}. Output: [{output.stdout.decode()}]'
)
else:
raise RuntimeError(
f'Failed to create user `{username}` with UID {user_id}. Output: [{output.stderr.decode()}]'
)
# First create the working directory, independent of the user
logger.debug(f'Client working directory: {initial_cwd}')
command = f'umask 002; mkdir -p {initial_cwd}'
output = subprocess.run(command, shell=True, capture_output=True)
out_str = output.stdout.decode()
# Get group ID from environment variable, default to 'root' for backward compatibility
group_id = os.getenv('SANDBOX_GROUP_ID', 'root')
command = f'chown -R {username}:{group_id} {initial_cwd}'
output = subprocess.run(command, shell=True, capture_output=True)
out_str += output.stdout.decode()
command = f'chmod g+rw {initial_cwd}'
output = subprocess.run(command, shell=True, capture_output=True)
out_str += output.stdout.decode()
logger.debug(f'Created working directory. Output: [{out_str}]')
return None if existing_user_id == -1 else existing_user_id
@@ -0,0 +1,400 @@
FROM {{ base_image }}
SHELL ["/bin/bash", "-c"]
# Shared environment variables
ENV POETRY_VIRTUALENVS_PATH=/openhands/poetry \
MAMBA_ROOT_PREFIX=/openhands/micromamba \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
EDITOR=code \
VISUAL=code \
GIT_EDITOR="code --wait" \
OPENVSCODE_SERVER_ROOT=/openhands/.openvscode-server
{% macro setup_base_system() %}
# Set PATH early to ensure system commands are available
ENV PATH="/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
# Install base system dependencies
{% if (('ubuntu' in base_image) or ('mswebench' in base_image)) %}
RUN apt-get update && \
apt-get install -y --no-install-recommends \
wget curl ca-certificates sudo apt-utils git jq tmux build-essential ripgrep ffmpeg \
coreutils util-linux procps findutils grep sed \
libasound2-plugins libatomic1 && \
(apt-get install -y --no-install-recommends libgl1 || apt-get install -y --no-install-recommends libgl1-mesa-glx) && \
# Install Docker dependencies
apt-get install -y --no-install-recommends apt-transport-https ca-certificates curl gnupg lsb-release && \
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
TZ=Etc/UTC DEBIAN_FRONTEND=noninteractive \
{%- if ('mswebench' in base_image) -%}
apt-get install -y --no-install-recommends nodejs python3 python-is-python3 python3-pip python3-venv
{%- else %}
apt-get install -y --no-install-recommends nodejs python3.12 python-is-python3 python3-pip python3.12-venv
{% endif -%}
{% endif %}
{% if (('ubuntu' not in base_image) and ('mswebench' not in base_image)) %}
RUN apt-get update && \
apt-get install -y --no-install-recommends \
wget curl ca-certificates sudo apt-utils git jq tmux build-essential ripgrep ffmpeg \
coreutils util-linux procps findutils grep sed \
libasound2-plugins libatomic1 && \
(apt-get install -y --no-install-recommends libgl1 || apt-get install -y --no-install-recommends libgl1-mesa-glx) && \
# Install Docker dependencies
apt-get install -y --no-install-recommends apt-transport-https ca-certificates curl gnupg lsb-release && \
# Security upgrade: patch GLib CVE-2025-14087 (buffer underflow in GVariant parser)
(apt-get install -y --no-install-recommends --only-upgrade \
libglib2.0-0t64 libglib2.0-bin libglib2.0-dev libglib2.0-dev-bin || true) && \
# Security upgrade: patch OpenSSL CVEs (CVE-2025-15467, CVE-2025-69419, CVE-2025-69421, et al.)
(apt-get install -y --no-install-recommends --only-upgrade \
openssl openssl-provider-legacy libssl3t64 || true) && \
# Security upgrade: patch ImageMagick CVEs (CVE-2026-25897, CVE-2026-25968, CVE-2026-26284, et al.)
(apt-get install -y --no-install-recommends --only-upgrade \
imagemagick imagemagick-7-common imagemagick-7.q16 \
libmagickcore-7-arch-config libmagickcore-7-headers \
libmagickcore-7.q16-10 libmagickcore-7.q16-10-extra \
libmagickcore-7.q16-dev libmagickcore-dev \
libmagickwand-7-headers libmagickwand-7.q16-10 \
libmagickwand-7.q16-dev libmagickwand-dev || true)
{% endif %}
{% if (('ubuntu' in base_image) or ('mswebench' in base_image)) %}
RUN ln -s "$(dirname $(which node))/corepack" /usr/local/bin/corepack && \
npm install -g corepack && corepack enable yarn && \
curl -fsSL --compressed https://install.python-poetry.org | python -
{% endif %}
# Install uv (required by MCP)
RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR="/openhands/bin" sh
# Add /openhands/bin to PATH
ENV PATH="/openhands/bin:${PATH}"
# Remove UID 1000 and GID 1000 users/groups that might conflict with openhands user
RUN (if getent passwd 1000 | grep -q pn; then userdel pn; fi) && \
(if getent passwd 1000 | grep -q ubuntu; then userdel ubuntu; fi) && \
(if getent group 1000 | grep -q pn; then groupdel pn; fi) && \
(if getent group 1000 | grep -q ubuntu; then groupdel ubuntu; fi)
# Create openhands group and user (with fallback IDs if 1000 is taken)
RUN (if getent group 1000 >/dev/null 2>&1; then \
groupadd openhands; \
else \
groupadd -g 1000 openhands; \
fi) && \
(if getent passwd 1000 >/dev/null 2>&1; then \
useradd -g openhands -m -s /bin/bash openhands; \
else \
useradd -u 1000 -g openhands -m -s /bin/bash openhands; \
fi) && \
usermod -aG sudo openhands && \
echo 'openhands ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers && \
# Set empty password for openhands user to allow passwordless su
passwd -d openhands && \
# Set empty password for root user as well to ensure su works in both directions
passwd -d root && \
# Ensure root can su to openhands without password by configuring PAM
sed -i '/pam_rootok.so/d' /etc/pam.d/su && \
sed -i '1i auth sufficient pam_rootok.so' /etc/pam.d/su
# Create necessary directories
RUN mkdir -p /openhands && \
mkdir -p /openhands/logs && \
mkdir -p /openhands/poetry && \
chown -R openhands:openhands /openhands
# ================================================================
# Define Docker installation macro
{% macro install_docker() %}
# Install Docker following official documentation
# https://docs.docker.com/engine/install/ubuntu/
# https://docs.docker.com/engine/install/debian/
RUN \
# Determine OS type and install accordingly
if [[ "{{ base_image }}" == *"ubuntu"* || "{{ base_image }}" == *"betty1202"* ]]; then \
# 'betty1202' for sweperf
# Handle Ubuntu (following https://docs.docker.com/engine/install/ubuntu/)
# Add Docker's official GPG key
apt-get update && \
apt-get install -y ca-certificates curl && \
install -m 0755 -d /etc/apt/keyrings && \
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc && \
chmod a+r /etc/apt/keyrings/docker.asc && \
# Add the repository to Apt sources
# For Ubuntu 24.04 (noble), use jammy repository as noble isn't supported yet
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null; \
else \
# Handle Debian (following https://docs.docker.com/engine/install/debian/)
# Add Docker's official GPG key
apt-get update && \
apt-get install -y ca-certificates curl && \
install -m 0755 -d /etc/apt/keyrings && \
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && \
chmod a+r /etc/apt/keyrings/docker.asc && \
# Add the repository to Apt sources (default to bookworm for stability)
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null; \
fi && \
# Install Docker Engine, containerd, and Docker Compose
apt-get update && \
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Configure Docker daemon with MTU 1450 to prevent packet fragmentation issues
RUN mkdir -p /etc/docker && \
echo '{"mtu": 1450}' > /etc/docker/daemon.json
{% endmacro %}
# Install Docker only if not a swebench or mswebench image
{% if not ('swebench' in base_image) and not ('mswebench' in base_image) %}
{{ install_docker() }}
{% endif %}
# ================================================================
{% endmacro %}
{% macro setup_vscode_server() %}
# Reference:
# 1. https://github.com/gitpod-io/openvscode-server
# 2. https://github.com/gitpod-io/openvscode-releases
# Setup VSCode Server
ARG RELEASE_TAG="openvscode-server-v1.98.2"
ARG RELEASE_ORG="gitpod-io"
# ARG USERNAME=openvscode-server
# ARG USER_UID=1000
# ARG USER_GID=1000
RUN if [ -z "${RELEASE_TAG}" ]; then \
echo "The RELEASE_TAG build arg must be set." >&2 && \
exit 1; \
fi && \
arch=$(uname -m) && \
if [ "${arch}" = "x86_64" ]; then \
arch="x64"; \
elif [ "${arch}" = "aarch64" ]; then \
arch="arm64"; \
elif [ "${arch}" = "armv7l" ]; then \
arch="armhf"; \
fi && \
wget https://github.com/${RELEASE_ORG}/openvscode-server/releases/download/${RELEASE_TAG}/${RELEASE_TAG}-linux-${arch}.tar.gz && \
tar -xzf ${RELEASE_TAG}-linux-${arch}.tar.gz && \
if [ -d "${OPENVSCODE_SERVER_ROOT}" ]; then rm -rf "${OPENVSCODE_SERVER_ROOT}"; fi && \
mv ${RELEASE_TAG}-linux-${arch} ${OPENVSCODE_SERVER_ROOT} && \
cp ${OPENVSCODE_SERVER_ROOT}/bin/remote-cli/openvscode-server ${OPENVSCODE_SERVER_ROOT}/bin/remote-cli/code && \
rm -f ${RELEASE_TAG}-linux-${arch}.tar.gz && \
chown -R openhands:openhands ${OPENVSCODE_SERVER_ROOT}
{% endmacro %}
{% macro install_vscode_extensions() %}
# Install our custom extensions as openhands user
USER openhands
RUN mkdir -p ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-hello-world && \
cp -r /openhands/code/openhands/runtime/utils/vscode-extensions/hello-world/* ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-hello-world/
RUN mkdir -p ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-memory-monitor && \
cp -r /openhands/code/openhands/runtime/utils/vscode-extensions/memory-monitor/* ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-memory-monitor/
# Some extension dirs are removed because they trigger false positives in vulnerability scans.
RUN rm -rf ${OPENVSCODE_SERVER_ROOT}/extensions/{handlebars,pug,json,diff,grunt,ini,npm}
{% endmacro %}
{% macro install_dependencies_root() %}
# Install system-level dependencies that require root
USER root
RUN \
{% if enable_browser %}
# Install system dependencies for Playwright (requires root)
apt-get update && \
apt-get install -y --no-install-recommends \
libnss3 libnspr4 libatk-bridge2.0-0 libdrm2 libxkbcommon0 libxcomposite1 \
libxdamage1 libxrandr2 libgbm1 libxss1 && \
# Install libasound2 - try new package name first (Ubuntu 24.04+), fallback to old name
(apt-get install -y --no-install-recommends libasound2t64 || apt-get install -y --no-install-recommends libasound2) && \
# Install Playwright browsers in shared location accessible to all users
export PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers && \
mkdir -p /opt/playwright-browsers && \
umask 022 && \
/openhands/micromamba/bin/micromamba run -n openhands poetry run playwright install --with-deps chromium && \
# Create cache directories and symlinks for both users
mkdir -p /home/openhands/.cache && \
mkdir -p /root/.cache && \
ln -sf /opt/playwright-browsers /home/openhands/.cache/ms-playwright && \
ln -sf /opt/playwright-browsers /root/.cache/ms-playwright && \
chown -h openhands:openhands /home/openhands/.cache/ms-playwright && \
# Set environment variable for all users
echo 'export PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers' >> /etc/environment && \
{% endif %}
# Set environment variables (requires root)
/openhands/micromamba/bin/micromamba run -n openhands poetry run python -c "import sys; print('OH_INTERPRETER_PATH=' + sys.executable)" >> /etc/environment && \
# Permissions: poetry and micromamba are already world-readable thanks to
# umask 022 set during install_dependencies_user and micromamba install.
# No cross-layer chmod -R needed, which avoids expensive overlay2 copy-ups.
mkdir -p /openhands/workspace && chmod -R g+rws,o+rw /openhands/workspace && \
chown -R openhands:openhands /openhands/workspace && \
# Ensure PATH includes system binaries early in startup
echo 'export PATH="/usr/bin:/bin:/usr/sbin:/sbin:$PATH"' >> /etc/environment && \
echo 'export PATH="/usr/bin:/bin:/usr/sbin:/sbin:$PATH"' >> /etc/bash.bashrc && \
# Set up conda environment activation for all users
echo 'eval "$(/openhands/micromamba/bin/micromamba shell hook --shell bash)"' >> /etc/bash.bashrc && \
echo 'micromamba activate openhands 2>/dev/null || true' >> /etc/bash.bashrc && \
# Set up environment for root user
echo 'export PATH="/usr/bin:/bin:/usr/sbin:/sbin:/openhands/micromamba/bin:$PATH"' >> /root/.bashrc && \
echo 'export PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers' >> /root/.bashrc && \
echo 'eval "$(/openhands/micromamba/bin/micromamba shell hook --shell bash)"' >> /root/.bashrc && \
echo 'micromamba activate openhands 2>/dev/null || true' >> /root/.bashrc && \
# Clean up system packages (requires root)
apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
{% endmacro %}
{% macro install_dependencies_user() %}
# Install user-level dependencies as openhands user
WORKDIR /openhands/code
USER openhands
RUN \
# Set umask so all files are created world-readable (dirs 755, files 644).
# This avoids expensive cross-layer chmod -R 755 in install_dependencies_root.
umask 022 && \
/openhands/micromamba/bin/micromamba config set changeps1 False && \
/openhands/micromamba/bin/micromamba run -n openhands poetry config virtualenvs.path /openhands/poetry && \
/openhands/micromamba/bin/micromamba run -n openhands poetry env use python3.12 && \
# Install project dependencies
/openhands/micromamba/bin/micromamba run -n openhands poetry install --only main,runtime --no-interaction --no-root && \
# Clean up user caches
/openhands/micromamba/bin/micromamba run -n openhands poetry cache clear --all . -n && \
/openhands/micromamba/bin/micromamba clean --all
{% endmacro %}
{% if build_from_scratch %}
# ================================================================
# START: Build Runtime Image from Scratch
# ================================================================
# This is used in cases where the base image is something more generic like nikolaik/python-nodejs
# rather than the current OpenHands release
{{ setup_base_system() }}
# Install micromamba
RUN mkdir -p /openhands/micromamba/bin && \
/bin/bash -c "PREFIX_LOCATION=/openhands/micromamba BIN_FOLDER=/openhands/micromamba/bin INIT_YES=no CONDA_FORGE_YES=yes $(curl -L https://micro.mamba.pm/install.sh)" && \
/openhands/micromamba/bin/micromamba config remove channels defaults && \
{%- if channel_alias %}
/openhands/micromamba/bin/micromamba config set channel_alias '{{ channel_alias }}' && \
{%- endif %}
/openhands/micromamba/bin/micromamba config list && \
chown -R openhands:openhands /openhands/micromamba && \
# Create read-only shared access to micromamba for all users
# This allows both root and openhands users to access the same packages
# while maintaining security by keeping openhands as the owner
chmod -R 755 /openhands/micromamba && \
# Create a separate writable location for root's micromamba cache/config
mkdir -p /root/.local/share/micromamba && \
# Set up environment variables for system-wide access
echo 'export PATH="/openhands/micromamba/bin:$PATH"' >> /etc/environment
# Create the openhands virtual environment and install poetry and python
# Run as openhands user to avoid expensive chown -R operations later
USER openhands
RUN umask 022 && \
/openhands/micromamba/bin/micromamba create -n openhands -y && \
/openhands/micromamba/bin/micromamba install -n openhands -c conda-forge poetry python=3.12 -y
USER root
# Create a clean openhands directory including only the pyproject.toml, poetry.lock and openhands/__init__.py
RUN \
if [ -d /openhands/code ]; then rm -rf /openhands/code; fi && \
mkdir -p /openhands/code/openhands && \
touch /openhands/code/openhands/__init__.py && \
chown -R openhands:openhands /openhands/code && \
# Set global git configuration to ensure proper author/committer information
git config --global user.name "openhands" && \
git config --global user.email "openhands@all-hands.dev"
COPY --chown=openhands:openhands ./code/pyproject.toml ./code/poetry.lock /openhands/code/
{{ install_dependencies_user() }}
{{ install_dependencies_root() }}
# ================================================================
# END: Build Runtime Image from Scratch
# ================================================================
{% endif %}
# Ensure openhands user/group and base dirs exist even when not building from scratch
USER root
RUN \
# Ensure group exists (prefer GID 1000 if available)
if ! getent group openhands >/dev/null 2>&1; then \
if getent group 1000 >/dev/null 2>&1; then groupadd openhands; else groupadd -g 1000 openhands; fi; \
fi && \
# Ensure user exists (prefer UID 1000 if available)
if ! id -u openhands >/dev/null 2>&1; then \
if getent passwd 1000 >/dev/null 2>&1; then useradd -m -s /bin/bash -g openhands openhands; else useradd -u 1000 -g openhands -m -s /bin/bash openhands; fi; \
fi && \
# Ensure home and required directories exist before later steps
mkdir -p /home/openhands && \
mkdir -p /openhands && \
mkdir -p $(dirname ${OPENVSCODE_SERVER_ROOT}) && \
# Fix ownership only on files not already owned by openhands.
# A blanket chown -R would trigger an overlay2 copy-up of every inode
# from prior layers even when ownership is already correct.
find /home/openhands /openhands \
! \( -user openhands -group openhands \) \
-exec chown openhands:openhands {} + 2>/dev/null || true
{{ setup_vscode_server() }}
# ================================================================
# Copy Project source files
# ================================================================
RUN if [ -d /openhands/code/openhands ]; then rm -rf /openhands/code/openhands; fi
COPY --chown=openhands:openhands ./code/pyproject.toml ./code/poetry.lock /openhands/code/
RUN if [ -d /openhands/code/skills ]; then rm -rf /openhands/code/skills; fi
COPY --chown=openhands:openhands ./code/skills /openhands/code/skills
COPY --chown=openhands:openhands ./code/openhands /openhands/code/openhands
RUN chmod a+rwx /openhands/code/openhands/__init__.py && \
chown -R openhands:openhands /openhands/code
# ================================================================
# Install VSCode extensions for build_from_scratch
# (must be after setup_vscode_server and source file copy)
# ================================================================
{% if build_from_scratch %}
{{ install_vscode_extensions() }}
{% endif %}
# ================================================================
# END: Build from versioned image
# ================================================================
{% if build_from_versioned %}
{{ install_dependencies_user() }}
{{ install_dependencies_root() }}
{{ install_vscode_extensions() }}
{% endif %}
# Install extra dependencies if specified (as openhands user)
{% if extra_deps %}
USER openhands
RUN {{ extra_deps }}
{% endif %}
# Set up environment for openhands user
USER root
RUN \
# Set up environment for openhands user
echo 'export PATH="/usr/bin:/bin:/usr/sbin:/sbin:/openhands/micromamba/bin:$PATH"' >> /home/openhands/.bashrc && \
echo 'export PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers' >> /home/openhands/.bashrc && \
echo 'eval "$(/openhands/micromamba/bin/micromamba shell hook --shell bash)"' >> /home/openhands/.bashrc && \
echo 'micromamba activate openhands 2>/dev/null || true' >> /home/openhands/.bashrc && \
chown openhands:openhands /home/openhands/.bashrc
+7
View File
@@ -0,0 +1,7 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
+75
View File
@@ -0,0 +1,75 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
import random
import socket
import time
def check_port_available(port: int) -> bool:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.bind(('0.0.0.0', port))
return True
except OSError:
time.sleep(0.1) # Short delay to further reduce chance of collisions
return False
finally:
sock.close()
def find_available_tcp_port(
min_port: int = 30000, max_port: int = 39999, max_attempts: int = 10
) -> int:
"""Find an available TCP port in a specified range.
Args:
min_port (int): The lower bound of the port range (default: 30000)
max_port (int): The upper bound of the port range (default: 39999)
max_attempts (int): Maximum number of attempts to find an available port (default: 10)
Returns:
int: An available port number, or -1 if none found after max_attempts
"""
rng = random.SystemRandom()
ports = list(range(min_port, max_port + 1))
rng.shuffle(ports)
for port in ports[:max_attempts]:
if check_port_available(port):
return port
return -1
def display_number_matrix(number: int) -> str | None:
if not 0 <= number <= 999:
return None
# Define the matrix representation for each digit
digits = {
'0': ['###', '# #', '# #', '# #', '###'],
'1': [' #', ' #', ' #', ' #', ' #'],
'2': ['###', ' #', '###', '# ', '###'],
'3': ['###', ' #', '###', ' #', '###'],
'4': ['# #', '# #', '###', ' #', ' #'],
'5': ['###', '# ', '###', ' #', '###'],
'6': ['###', '# ', '###', '# #', '###'],
'7': ['###', ' #', ' #', ' #', ' #'],
'8': ['###', '# #', '###', '# #', '###'],
'9': ['###', '# #', '###', ' #', '###'],
}
# alternatively, with leading zeros: num_str = f"{number:03d}"
num_str = str(number) # Convert to string without padding
result = []
for row in range(5):
line = ' '.join(digits[digit][row] for digit in num_str)
result.append(line)
matrix_display = '\n'.join(result)
return f'\n{matrix_display}\n'

Some files were not shown because too many files have changed in this diff Show More