mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
(arch) Switch default runtime to EventStream Runtime (#3271)
* switch default to eventstream runtime * remove pull docker from makefile * fix unittest * fix file store path * try deprecate server runtime * remove persist sandbox * move file utils * remove server runtime related workflow * remove unused method * attempt to remove the reliance on filestore for BE * fix async for list file * fix list_files to post * fix list files * add suffix to directory * make sure list file returns abs path; make sure other backend endpoints accpets abs path * remove server runtime test workflow * set git config in runtime
This commit is contained in:
parent
71ad979ffd
commit
90d0a62469
3
.github/workflows/dummy-agent-test.yml
vendored
3
.github/workflows/dummy-agent-test.yml
vendored
@ -11,9 +11,6 @@ on:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
PERSIST_SANDBOX : 'false'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
65
.github/workflows/ghcr.yml
vendored
65
.github/workflows/ghcr.yml
vendored
@ -25,7 +25,7 @@ on:
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
# Builds the OpenDevin and sandbox Docker images
|
||||
# Builds the OpenDevin Docker images
|
||||
ghcr_build:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
@ -35,7 +35,7 @@ jobs:
|
||||
packages: write
|
||||
strategy:
|
||||
matrix:
|
||||
image: ['sandbox', 'opendevin']
|
||||
image: ['opendevin']
|
||||
platform: ['amd64', 'arm64']
|
||||
steps:
|
||||
- name: Checkout
|
||||
@ -142,11 +142,9 @@ jobs:
|
||||
name: Test Runtime
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ghcr_build_runtime, ghcr_build]
|
||||
env:
|
||||
PERSIST_SANDBOX: 'false'
|
||||
strategy:
|
||||
matrix:
|
||||
runtime_type: ['eventstream', 'server']
|
||||
runtime_type: ['eventstream']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
@ -204,60 +202,11 @@ jobs:
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
# Run integration tests with the sandbox Docker images
|
||||
integration_tests_on_linux:
|
||||
name: Integration Tests on Linux
|
||||
runs-on: ubuntu-latest
|
||||
needs: ghcr_build
|
||||
env:
|
||||
PERSIST_SANDBOX: "false"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ['3.11']
|
||||
sandbox: ['ssh', 'local']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'poetry'
|
||||
- name: Install Python dependencies using Poetry
|
||||
run: make install-python-dependencies
|
||||
- name: Download sandbox Docker image
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: sandbox-docker-image-amd64
|
||||
path: /tmp/
|
||||
- name: Load sandbox image and run integration tests
|
||||
env:
|
||||
SANDBOX_BOX_TYPE: ${{ matrix.sandbox }}
|
||||
run: |
|
||||
# Load the Docker image and capture the output
|
||||
output=$(docker load -i /tmp/sandbox_image_amd64.tar)
|
||||
|
||||
# Extract the first image name from the output
|
||||
image_name=$(echo "$output" | grep -oP 'Loaded image: \K.*' | head -n 1)
|
||||
|
||||
# Print the full name of the image
|
||||
echo "Loaded Docker image: $image_name"
|
||||
|
||||
SANDBOX_CONTAINER_IMAGE=$image_name TEST_IN_CI=true TEST_ONLY=true TEST_RUNTIME=server ./tests/integration/regenerate.sh
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
# Run integration tests with the eventstream runtime Docker image
|
||||
runtime_integration_tests_on_linux:
|
||||
name: Runtime Integration Tests on Linux
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ghcr_build_runtime]
|
||||
env:
|
||||
PERSIST_SANDBOX: 'false'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@ -305,7 +254,7 @@ jobs:
|
||||
# Push the OpenDevin and sandbox Docker images to the ghcr.io repository
|
||||
ghcr_push:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ghcr_build, test_runtime, integration_tests_on_linux]
|
||||
needs: [ghcr_build]
|
||||
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
tags: ${{ needs.ghcr_build.outputs.tags }}
|
||||
@ -314,7 +263,7 @@ jobs:
|
||||
packages: write
|
||||
strategy:
|
||||
matrix:
|
||||
image: ['sandbox', 'opendevin']
|
||||
image: ['opendevin']
|
||||
platform: ['amd64', 'arm64']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@ -347,7 +296,7 @@ jobs:
|
||||
# Push the runtime Docker images to the ghcr.io repository
|
||||
ghcr_push_runtime:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ghcr_build_runtime, test_runtime, integration_tests_on_linux]
|
||||
needs: [ghcr_build_runtime, test_runtime, runtime_integration_tests_on_linux]
|
||||
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
tags: ${{ needs.ghcr_build_runtime.outputs.tags }}
|
||||
@ -416,7 +365,7 @@ jobs:
|
||||
tags: ${{ needs.ghcr_build.outputs.tags }}
|
||||
strategy:
|
||||
matrix:
|
||||
image: ['sandbox', 'opendevin']
|
||||
image: ['opendevin']
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
2
.github/workflows/run-unit-tests.yml
vendored
2
.github/workflows/run-unit-tests.yml
vendored
@ -16,8 +16,6 @@ on:
|
||||
- 'evaluation/**'
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
PERSIST_SANDBOX : "false"
|
||||
|
||||
jobs:
|
||||
# Run frontend unit tests
|
||||
|
||||
20
Makefile
20
Makefile
@ -23,9 +23,6 @@ RESET=$(shell tput -Txterm sgr0)
|
||||
build:
|
||||
@echo "$(GREEN)Building project...$(RESET)"
|
||||
@$(MAKE) -s check-dependencies
|
||||
ifeq ($(INSTALL_DOCKER),)
|
||||
@$(MAKE) -s pull-docker-image
|
||||
endif
|
||||
@$(MAKE) -s install-python-dependencies
|
||||
@$(MAKE) -s install-frontend-dependencies
|
||||
@$(MAKE) -s install-pre-commit-hooks
|
||||
@ -124,11 +121,6 @@ check-poetry:
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
pull-docker-image:
|
||||
@echo "$(YELLOW)Pulling Docker image...$(RESET)"
|
||||
@docker pull $(DOCKER_IMAGE)
|
||||
@echo "$(GREEN)Docker image pulled successfully.$(RESET)"
|
||||
|
||||
install-python-dependencies:
|
||||
@echo "$(GREEN)Installing Python dependencies...$(RESET)"
|
||||
@if [ -z "${TZ}" ]; then \
|
||||
@ -246,16 +238,6 @@ setup-config-prompts:
|
||||
workspace_dir=$${workspace_dir:-$(DEFAULT_WORKSPACE_DIR)}; \
|
||||
echo "workspace_base=\"$$workspace_dir\"" >> $(CONFIG_FILE).tmp
|
||||
|
||||
@read -p "Do you want to persist the sandbox container? [true/false] [default: false]: " persist_sandbox; \
|
||||
persist_sandbox=$${persist_sandbox:-false}; \
|
||||
if [ "$$persist_sandbox" = "true" ]; then \
|
||||
read -p "Enter a password for the sandbox container: " ssh_password; \
|
||||
echo "ssh_password=\"$$ssh_password\"" >> $(CONFIG_FILE).tmp; \
|
||||
echo "persist_sandbox=$$persist_sandbox" >> $(CONFIG_FILE).tmp; \
|
||||
else \
|
||||
echo "persist_sandbox=$$persist_sandbox" >> $(CONFIG_FILE).tmp; \
|
||||
fi
|
||||
|
||||
@echo "" >> $(CONFIG_FILE).tmp
|
||||
|
||||
@echo "[llm]" >> $(CONFIG_FILE).tmp
|
||||
@ -316,4 +298,4 @@ help:
|
||||
@echo " $(GREEN)help$(RESET) - Display this help message, providing information on available targets."
|
||||
|
||||
# Phony targets
|
||||
.PHONY: build check-dependencies check-python check-npm check-docker check-poetry pull-docker-image install-python-dependencies install-frontend-dependencies install-pre-commit-hooks lint start-backend start-frontend run run-wsl setup-config setup-config-prompts help
|
||||
.PHONY: build check-dependencies check-python check-npm check-docker check-poetry install-python-dependencies install-frontend-dependencies install-pre-commit-hooks lint start-backend start-frontend run run-wsl setup-config setup-config-prompts help
|
||||
|
||||
@ -208,9 +208,3 @@ class DummyAgent(Agent):
|
||||
f' Unable to perform interactive browsing: {action.browser_actions}'
|
||||
)
|
||||
return MessageAction(content=message)
|
||||
|
||||
async def get_working_directory(self, state: State) -> str:
|
||||
# Implement this method to return the current working directory
|
||||
# This might involve accessing state information or making an async call
|
||||
# For now, we'll return a placeholder value
|
||||
return './workspace'
|
||||
|
||||
@ -55,8 +55,6 @@ workspace_base = "./workspace"
|
||||
# Path to rewrite the workspace mount path to
|
||||
#workspace_mount_rewrite = ""
|
||||
|
||||
# Persist the sandbox
|
||||
persist_sandbox = false
|
||||
|
||||
# Run as devin
|
||||
#run_as_devin = true
|
||||
|
||||
@ -108,7 +108,7 @@ function FileExplorer() {
|
||||
}
|
||||
dispatch(setRefreshID(Math.random()));
|
||||
try {
|
||||
const fileList = await listFiles("/");
|
||||
const fileList = await listFiles();
|
||||
setFiles(fileList);
|
||||
} catch (error) {
|
||||
toast.error("refresh-error", t(I18nKey.EXPLORER$REFRESH_ERROR_MESSAGE));
|
||||
|
||||
@ -67,10 +67,14 @@ export async function uploadFiles(files: FileList): Promise<UploadResult> {
|
||||
};
|
||||
}
|
||||
|
||||
export async function listFiles(path: string = "/"): Promise<string[]> {
|
||||
const data = await request(
|
||||
`/api/list-files?path=${encodeURIComponent(path)}`,
|
||||
);
|
||||
export async function listFiles(
|
||||
path: string | undefined = undefined,
|
||||
): Promise<string[]> {
|
||||
let url = "/api/list-files";
|
||||
if (path) {
|
||||
url = `/api/list-files?path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
const data = await request(url);
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error("Invalid response format: data is not an array");
|
||||
}
|
||||
|
||||
@ -166,10 +166,8 @@ class SandboxConfig(metaclass=Singleton):
|
||||
"""
|
||||
|
||||
box_type: str = 'ssh'
|
||||
container_image: str = 'ghcr.io/opendevin/sandbox' + (
|
||||
f':{os.getenv("OPEN_DEVIN_BUILD_VERSION")}'
|
||||
if os.getenv('OPEN_DEVIN_BUILD_VERSION')
|
||||
else ':main'
|
||||
container_image: str = (
|
||||
'ubuntu:22.04' # default to ubuntu:22.04 for eventstream runtime
|
||||
)
|
||||
user_id: int = os.getuid() if hasattr(os, 'getuid') else 1000
|
||||
timeout: int = 120
|
||||
@ -241,7 +239,7 @@ class AppConfig(metaclass=Singleton):
|
||||
agents: dict = field(default_factory=dict)
|
||||
default_agent: str = _DEFAULT_AGENT
|
||||
sandbox: SandboxConfig = field(default_factory=SandboxConfig)
|
||||
runtime: str = 'server'
|
||||
runtime: str = 'eventstream'
|
||||
file_store: str = 'memory'
|
||||
file_store_path: str = '/tmp/file_store'
|
||||
# TODO: clean up workspace path after the removal of ServerRuntime
|
||||
@ -259,7 +257,6 @@ class AppConfig(metaclass=Singleton):
|
||||
e2b_api_key: str = ''
|
||||
ssh_hostname: str = 'localhost'
|
||||
disable_color: bool = False
|
||||
persist_sandbox: bool = False
|
||||
ssh_port: int = 63710
|
||||
ssh_password: str | None = None
|
||||
jwt_secret: str = uuid.uuid4().hex
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from typing import Callable, Type
|
||||
@ -23,7 +22,6 @@ from opendevin.events.observation import AgentStateChangedObservation
|
||||
from opendevin.llm.llm import LLM
|
||||
from opendevin.runtime import get_runtime_cls
|
||||
from opendevin.runtime.runtime import Runtime
|
||||
from opendevin.runtime.server.runtime import ServerRuntime
|
||||
from opendevin.storage import get_file_store
|
||||
|
||||
|
||||
@ -68,22 +66,6 @@ async def create_runtime(
|
||||
)
|
||||
await runtime.ainit()
|
||||
|
||||
if isinstance(runtime, ServerRuntime):
|
||||
runtime.init_runtime_tools(
|
||||
agent_cls.runtime_tools,
|
||||
runtime_tools_config=runtime_tools_config,
|
||||
)
|
||||
# browser eval specific
|
||||
# NOTE: This will be deprecated when we move to the new runtime
|
||||
if runtime.browser and runtime.browser.eval_dir:
|
||||
logger.info(f'Evaluation directory: {runtime.browser.eval_dir}')
|
||||
with open(
|
||||
os.path.join(runtime.browser.eval_dir, 'goal.txt'),
|
||||
'r',
|
||||
encoding='utf-8',
|
||||
) as f:
|
||||
task_str = f.read()
|
||||
logger.info(f'Dynamic Eval task: {task_str}')
|
||||
return runtime
|
||||
|
||||
|
||||
|
||||
@ -1,16 +1,10 @@
|
||||
from .docker.local_box import LocalBox
|
||||
from .docker.ssh_box import DockerSSHBox
|
||||
from .e2b.sandbox import E2BBox
|
||||
from .sandbox import Sandbox
|
||||
|
||||
|
||||
def get_runtime_cls(name: str):
|
||||
# Local imports to avoid circular imports
|
||||
if name == 'server':
|
||||
from .server.runtime import ServerRuntime
|
||||
|
||||
return ServerRuntime
|
||||
elif name == 'eventstream':
|
||||
if name == 'eventstream':
|
||||
from .client.runtime import EventStreamRuntime
|
||||
|
||||
return EventStreamRuntime
|
||||
|
||||
@ -17,6 +17,8 @@ from pathlib import Path
|
||||
import pexpect
|
||||
from fastapi import FastAPI, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
from pathspec import PathSpec
|
||||
from pathspec.patterns import GitWildMatchPattern
|
||||
from pydantic import BaseModel
|
||||
from uvicorn import run
|
||||
|
||||
@ -46,8 +48,8 @@ from opendevin.runtime.plugins import (
|
||||
JupyterPlugin,
|
||||
Plugin,
|
||||
)
|
||||
from opendevin.runtime.server.files import insert_lines, read_lines
|
||||
from opendevin.runtime.utils import split_bash_commands
|
||||
from opendevin.runtime.utils.files import insert_lines, read_lines
|
||||
|
||||
|
||||
class ActionRequest(BaseModel):
|
||||
@ -76,6 +78,11 @@ class RuntimeClient:
|
||||
self.lock = asyncio.Lock()
|
||||
self.plugins: dict[str, Plugin] = {}
|
||||
self.browser = BrowserEnv(browsergym_eval_env)
|
||||
self._initial_pwd = work_dir
|
||||
|
||||
@property
|
||||
def initial_pwd(self):
|
||||
return self._initial_pwd
|
||||
|
||||
async def ainit(self):
|
||||
for plugin in self.plugins_to_load:
|
||||
@ -499,6 +506,130 @@ if __name__ == '__main__':
|
||||
async def alive():
|
||||
return {'status': 'ok'}
|
||||
|
||||
# ================================
|
||||
# File-specific operations for UI
|
||||
# ================================
|
||||
|
||||
@app.post('/list_files')
|
||||
async def list_files(request: Request):
|
||||
"""List files in the specified path.
|
||||
|
||||
This function retrieves a list of files from the agent's runtime file store,
|
||||
excluding certain system and hidden files/directories.
|
||||
|
||||
To list files:
|
||||
```sh
|
||||
curl http://localhost:3000/api/list-files
|
||||
```
|
||||
|
||||
Args:
|
||||
request (Request): The incoming request object.
|
||||
path (str, optional): The path to list files from. Defaults to '/'.
|
||||
|
||||
Returns:
|
||||
list: A list of file names in the specified path.
|
||||
|
||||
Raises:
|
||||
HTTPException: If there's an error listing the files.
|
||||
"""
|
||||
assert client is not None
|
||||
|
||||
# get request as dict
|
||||
request_dict = await request.json()
|
||||
path = request_dict.get('path', None)
|
||||
|
||||
# Get the full path of the requested directory
|
||||
if path is None:
|
||||
full_path = client.initial_pwd
|
||||
elif os.path.isabs(path):
|
||||
full_path = path
|
||||
else:
|
||||
full_path = os.path.join(client.initial_pwd, path)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
return JSONResponse(
|
||||
content={'error': f'Directory {full_path} does not exist'},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
# Check if the directory exists
|
||||
if not os.path.exists(full_path) or not os.path.isdir(full_path):
|
||||
return []
|
||||
|
||||
# Check if .gitignore exists
|
||||
gitignore_path = os.path.join(full_path, '.gitignore')
|
||||
if os.path.exists(gitignore_path):
|
||||
# Use PathSpec to parse .gitignore
|
||||
with open(gitignore_path, 'r') as f:
|
||||
spec = PathSpec.from_lines(GitWildMatchPattern, f.readlines())
|
||||
else:
|
||||
# Fallback to default exclude list if .gitignore doesn't exist
|
||||
default_exclude = [
|
||||
'.git',
|
||||
'.DS_Store',
|
||||
'.svn',
|
||||
'.hg',
|
||||
'.idea',
|
||||
'.vscode',
|
||||
'.settings',
|
||||
'.pytest_cache',
|
||||
'__pycache__',
|
||||
'node_modules',
|
||||
'vendor',
|
||||
'build',
|
||||
'dist',
|
||||
'bin',
|
||||
'logs',
|
||||
'log',
|
||||
'tmp',
|
||||
'temp',
|
||||
'coverage',
|
||||
'venv',
|
||||
'env',
|
||||
]
|
||||
spec = PathSpec.from_lines(GitWildMatchPattern, default_exclude)
|
||||
|
||||
entries = os.listdir(full_path)
|
||||
|
||||
# Filter entries using PathSpec
|
||||
filtered_entries = [
|
||||
os.path.join(full_path, entry)
|
||||
for entry in entries
|
||||
if not spec.match_file(os.path.relpath(entry, str(full_path)))
|
||||
]
|
||||
|
||||
# Separate directories and files
|
||||
directories = []
|
||||
files = []
|
||||
for entry in filtered_entries:
|
||||
# Remove leading slash and any parent directory components
|
||||
entry_relative = entry.lstrip('/').split('/')[-1]
|
||||
|
||||
# Construct the full path by joining the base path with the relative entry path
|
||||
full_entry_path = os.path.join(full_path, entry_relative)
|
||||
if os.path.exists(full_entry_path):
|
||||
is_dir = os.path.isdir(full_entry_path)
|
||||
if is_dir:
|
||||
# add trailing slash to directories
|
||||
# required by FE to differentiate directories and files
|
||||
entry = entry.rstrip('/') + '/'
|
||||
directories.append(entry)
|
||||
else:
|
||||
files.append(entry)
|
||||
|
||||
# Sort directories and files separately
|
||||
directories.sort(key=lambda s: s.lower())
|
||||
files.sort(key=lambda s: s.lower())
|
||||
|
||||
# Combine sorted directories and files
|
||||
sorted_entries = directories + files
|
||||
return sorted_entries
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error listing files: {e}', exc_info=True)
|
||||
return []
|
||||
|
||||
logger.info(f'Starting action execution API on port {args.port}')
|
||||
print(f'Starting action execution API on port {args.port}')
|
||||
run(app, host='0.0.0.0', port=args.port)
|
||||
|
||||
@ -2,7 +2,7 @@ import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
from typing import Optional
|
||||
from zipfile import ZipFile
|
||||
|
||||
import aiohttp
|
||||
@ -22,6 +22,7 @@ from opendevin.events.action import (
|
||||
)
|
||||
from opendevin.events.action.action import Action
|
||||
from opendevin.events.observation import (
|
||||
CmdOutputObservation,
|
||||
ErrorObservation,
|
||||
NullObservation,
|
||||
Observation,
|
||||
@ -30,7 +31,6 @@ from opendevin.events.serialization import event_to_dict, observation_from_dict
|
||||
from opendevin.events.serialization.action import ACTION_TYPE_TO_CLASS
|
||||
from opendevin.runtime.plugins import PluginRequirement
|
||||
from opendevin.runtime.runtime import Runtime
|
||||
from opendevin.runtime.tools import RuntimeTool
|
||||
from opendevin.runtime.utils import find_available_tcp_port
|
||||
from opendevin.runtime.utils.runtime_build import build_runtime_image
|
||||
|
||||
@ -98,6 +98,8 @@ class EventStreamRuntime(Runtime):
|
||||
)
|
||||
logger.info(f'Container initialized with env vars: {env_vars}')
|
||||
|
||||
await self._init_git_config()
|
||||
|
||||
@staticmethod
|
||||
def _init_docker_client() -> docker.DockerClient:
|
||||
try:
|
||||
@ -181,6 +183,16 @@ class EventStreamRuntime(Runtime):
|
||||
await self.close(close_client=False)
|
||||
raise e
|
||||
|
||||
async def _init_git_config(self):
|
||||
action = CmdRunAction(
|
||||
'git config --global user.name "opendevin" && '
|
||||
'git config --global user.email "opendevin@all-hands.dev"'
|
||||
)
|
||||
logger.info(f'Setting git config: {action}')
|
||||
obs: Observation = await self.run_action(action)
|
||||
assert isinstance(obs, CmdOutputObservation)
|
||||
assert obs.exit_code == 0, f'Failed to set git config: {obs}'
|
||||
|
||||
async def _ensure_session(self):
|
||||
await asyncio.sleep(1)
|
||||
if self.session is None or self.session.closed:
|
||||
@ -224,56 +236,6 @@ class EventStreamRuntime(Runtime):
|
||||
if close_client:
|
||||
self.docker_client.close()
|
||||
|
||||
async 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')
|
||||
|
||||
session = await self._ensure_session()
|
||||
await self._wait_until_alive()
|
||||
try:
|
||||
if recursive:
|
||||
# For recursive copy, create a zip file
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix='.zip', delete=False
|
||||
) as temp_zip:
|
||||
temp_zip_path = temp_zip.name
|
||||
|
||||
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)
|
||||
|
||||
upload_data = {'file': open(temp_zip_path, 'rb')}
|
||||
else:
|
||||
# For single file copy
|
||||
upload_data = {'file': open(host_src, 'rb')}
|
||||
|
||||
params = {'destination': sandbox_dest, 'recursive': str(recursive).lower()}
|
||||
|
||||
async with session.post(
|
||||
f'{self.api_url}/upload_file', data=upload_data, params=params
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return
|
||||
else:
|
||||
error_message = await response.text()
|
||||
raise Exception(f'Copy operation failed: {error_message}')
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
raise TimeoutError('Copy operation timed out')
|
||||
except Exception as e:
|
||||
raise RuntimeError(f'Copy operation failed: {str(e)}')
|
||||
finally:
|
||||
if recursive:
|
||||
os.unlink(temp_zip_path)
|
||||
logger.info(f'Copy completed: host:{host_src} -> runtime:{sandbox_dest}')
|
||||
|
||||
async def run_action(self, action: Action) -> Observation:
|
||||
# set timeout to default if not set
|
||||
if action.timeout is None:
|
||||
@ -340,19 +302,83 @@ class EventStreamRuntime(Runtime):
|
||||
async def browse_interactive(self, action: BrowseInteractiveAction) -> Observation:
|
||||
return await self.run_action(action)
|
||||
|
||||
############################################################################
|
||||
# Keep the same with other runtimes
|
||||
############################################################################
|
||||
# ====================================================================
|
||||
# Implement these methods (for file operations) in the subclass
|
||||
# ====================================================================
|
||||
|
||||
def get_working_directory(self):
|
||||
raise NotImplementedError(
|
||||
'This method is not implemented in the runtime client.'
|
||||
)
|
||||
|
||||
def init_runtime_tools(
|
||||
self,
|
||||
runtime_tools: list[RuntimeTool],
|
||||
runtime_tools_config: Optional[dict[RuntimeTool, Any]] = None,
|
||||
async def copy_to(
|
||||
self, host_src: str, sandbox_dest: str, recursive: bool = False
|
||||
) -> None:
|
||||
# TODO: deprecate this method when we move to the new EventStreamRuntime
|
||||
logger.warning('init_runtime_tools is not implemented in the runtime client.')
|
||||
if not os.path.exists(host_src):
|
||||
raise FileNotFoundError(f'Source file {host_src} does not exist')
|
||||
|
||||
session = await self._ensure_session()
|
||||
await self._wait_until_alive()
|
||||
try:
|
||||
if recursive:
|
||||
# For recursive copy, create a zip file
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix='.zip', delete=False
|
||||
) as temp_zip:
|
||||
temp_zip_path = temp_zip.name
|
||||
|
||||
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)
|
||||
|
||||
upload_data = {'file': open(temp_zip_path, 'rb')}
|
||||
else:
|
||||
# For single file copy
|
||||
upload_data = {'file': open(host_src, 'rb')}
|
||||
|
||||
params = {'destination': sandbox_dest, 'recursive': str(recursive).lower()}
|
||||
|
||||
async with session.post(
|
||||
f'{self.api_url}/upload_file', data=upload_data, params=params
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return
|
||||
else:
|
||||
error_message = await response.text()
|
||||
raise Exception(f'Copy operation failed: {error_message}')
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
raise TimeoutError('Copy operation timed out')
|
||||
except Exception as e:
|
||||
raise RuntimeError(f'Copy operation failed: {str(e)}')
|
||||
finally:
|
||||
if recursive:
|
||||
os.unlink(temp_zip_path)
|
||||
logger.info(f'Copy completed: host:{host_src} -> runtime:{sandbox_dest}')
|
||||
|
||||
async 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).
|
||||
"""
|
||||
session = await self._ensure_session()
|
||||
await self._wait_until_alive()
|
||||
try:
|
||||
data = {}
|
||||
if path is not None:
|
||||
data['path'] = path
|
||||
|
||||
async with session.post(
|
||||
f'{self.api_url}/list_files', json=data
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
response_json = await response.json()
|
||||
assert isinstance(response_json, list)
|
||||
return response_json
|
||||
else:
|
||||
error_message = await response.text()
|
||||
raise Exception(f'List files operation failed: {error_message}')
|
||||
except asyncio.TimeoutError:
|
||||
raise TimeoutError('List files operation timed out')
|
||||
except Exception as e:
|
||||
raise RuntimeError(f'List files operation failed: {str(e)}')
|
||||
|
||||
@ -1,122 +0,0 @@
|
||||
import atexit
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from opendevin.core.config import SandboxConfig
|
||||
from opendevin.core.logger import opendevin_logger as logger
|
||||
from opendevin.core.schema import CancellableStream
|
||||
from opendevin.runtime.sandbox import Sandbox
|
||||
|
||||
# ===============================================================================
|
||||
# ** WARNING **
|
||||
#
|
||||
# This sandbox should only be used when OpenDevin is running inside a container
|
||||
#
|
||||
# Sandboxes are generally isolated so that they cannot affect the host machine.
|
||||
# This Sandbox implementation does not provide isolation, and can inadvertently
|
||||
# run dangerous commands on the host machine, potentially rendering the host
|
||||
# machine unusable.
|
||||
#
|
||||
# This sandbox is meant for use with OpenDevin Quickstart
|
||||
#
|
||||
# DO NOT USE THIS SANDBOX IN A PRODUCTION ENVIRONMENT
|
||||
# ===============================================================================
|
||||
|
||||
|
||||
class LocalBox(Sandbox):
|
||||
def __init__(
|
||||
self,
|
||||
config: SandboxConfig,
|
||||
workspace_base: str,
|
||||
):
|
||||
self.config = config
|
||||
os.makedirs(workspace_base, exist_ok=True)
|
||||
self.workspace_base = workspace_base
|
||||
atexit.register(self.cleanup)
|
||||
super().__init__(config)
|
||||
|
||||
def execute(
|
||||
self, cmd: str, stream: bool = False, timeout: int | None = None
|
||||
) -> tuple[int, str | CancellableStream]:
|
||||
try:
|
||||
completed_process = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=self.config.timeout,
|
||||
cwd=self.workspace_base,
|
||||
env=self._env,
|
||||
)
|
||||
return completed_process.returncode, completed_process.stdout.strip()
|
||||
except subprocess.TimeoutExpired:
|
||||
return -1, 'Command timed out'
|
||||
|
||||
def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False):
|
||||
# mkdir -p sandbox_dest if it doesn't exist
|
||||
res = subprocess.run(
|
||||
f'mkdir -p {sandbox_dest}',
|
||||
shell=True,
|
||||
text=True,
|
||||
cwd=self.workspace_base,
|
||||
env=self._env,
|
||||
)
|
||||
if res.returncode != 0:
|
||||
raise RuntimeError(f'Failed to create directory {sandbox_dest} in sandbox')
|
||||
|
||||
if recursive:
|
||||
res = subprocess.run(
|
||||
f'cp -r {host_src} {sandbox_dest}',
|
||||
shell=True,
|
||||
text=True,
|
||||
cwd=self.workspace_base,
|
||||
env=self._env,
|
||||
)
|
||||
if res.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f'Failed to copy {host_src} to {sandbox_dest} in sandbox'
|
||||
)
|
||||
else:
|
||||
res = subprocess.run(
|
||||
f'cp {host_src} {sandbox_dest}',
|
||||
shell=True,
|
||||
text=True,
|
||||
cwd=self.workspace_base,
|
||||
env=self._env,
|
||||
)
|
||||
if res.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f'Failed to copy {host_src} to {sandbox_dest} in sandbox'
|
||||
)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def cleanup(self):
|
||||
self.close()
|
||||
|
||||
def get_working_directory(self):
|
||||
return self.workspace_base
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
local_box = LocalBox(SandboxConfig(), '/tmp/opendevin')
|
||||
sys.stdout.flush()
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
user_input = input('>>> ')
|
||||
except EOFError:
|
||||
logger.info('Exiting...')
|
||||
break
|
||||
if user_input.lower() == 'exit':
|
||||
logger.info('Exiting...')
|
||||
break
|
||||
exit_code, output = local_box.execute(user_input)
|
||||
logger.info('exit code: %d', exit_code)
|
||||
logger.info(output)
|
||||
sys.stdout.flush()
|
||||
except KeyboardInterrupt:
|
||||
logger.info('Exiting...')
|
||||
local_box.close()
|
||||
@ -1,693 +0,0 @@
|
||||
import atexit
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from glob import glob
|
||||
|
||||
import docker
|
||||
from pexpect import exceptions, pxssh
|
||||
from tenacity import retry, stop_after_attempt, wait_fixed
|
||||
|
||||
from opendevin.core.config import SandboxConfig
|
||||
from opendevin.core.const.guide_url import TROUBLESHOOTING_URL
|
||||
from opendevin.core.logger import opendevin_logger as logger
|
||||
from opendevin.core.schema import CancellableStream
|
||||
from opendevin.runtime.plugins import AgentSkillsRequirement, JupyterRequirement
|
||||
from opendevin.runtime.plugins.requirement import PluginRequirement
|
||||
from opendevin.runtime.sandbox import Sandbox
|
||||
from opendevin.runtime.utils import find_available_tcp_port, split_bash_commands
|
||||
from opendevin.runtime.utils.image_agnostic import get_od_sandbox_image
|
||||
|
||||
|
||||
class SSHExecCancellableStream(CancellableStream):
|
||||
def __init__(self, ssh, cmd, timeout):
|
||||
super().__init__(self.read_output())
|
||||
self.ssh = ssh
|
||||
self.cmd = cmd
|
||||
self.timeout = timeout
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
def exit_code(self):
|
||||
marker = f'EXIT_CODE_MARKER_{uuid.uuid4().hex}'
|
||||
self.ssh.sendline(f'echo "{marker}$?{marker}"')
|
||||
|
||||
if not self.ssh.prompt(timeout=self.timeout):
|
||||
return None # Timeout occurred
|
||||
|
||||
output = self.ssh.before
|
||||
match = re.search(f'{marker}(\\d+){marker}', output)
|
||||
|
||||
if match:
|
||||
try:
|
||||
return int(match.group(1))
|
||||
except ValueError:
|
||||
# Log the unexpected format
|
||||
logger.error(f'Unexpected exit code format: {match.group(1)}')
|
||||
return None
|
||||
else:
|
||||
# If we can't find our marked exit code, log the output and return None
|
||||
logger.error(f'Could not find exit code in output: {output}')
|
||||
return None
|
||||
|
||||
def read_output(self):
|
||||
st = time.time()
|
||||
buf = ''
|
||||
crlf = '\r\n'
|
||||
lf = '\n'
|
||||
prompt_len = len(self.ssh.PROMPT)
|
||||
while True:
|
||||
try:
|
||||
if self.closed:
|
||||
break
|
||||
_output = self.ssh.read_nonblocking(timeout=1)
|
||||
if not _output:
|
||||
continue
|
||||
|
||||
buf += _output
|
||||
|
||||
if len(buf) < prompt_len:
|
||||
continue
|
||||
|
||||
match = re.search(self.ssh.PROMPT, buf)
|
||||
if match:
|
||||
idx, _ = match.span()
|
||||
yield buf[:idx].replace(crlf, lf)
|
||||
buf = ''
|
||||
break
|
||||
|
||||
res = buf[:-prompt_len]
|
||||
if len(res) == 0 or res.find(crlf) == -1:
|
||||
continue
|
||||
buf = buf[-prompt_len:]
|
||||
yield res.replace(crlf, lf)
|
||||
except exceptions.TIMEOUT:
|
||||
if time.time() - st < self.timeout:
|
||||
match = re.search(self.ssh.PROMPT, buf)
|
||||
if match:
|
||||
idx, _ = match.span()
|
||||
yield buf[:idx].replace(crlf, lf)
|
||||
break
|
||||
continue
|
||||
else:
|
||||
yield buf.replace(crlf, lf)
|
||||
break
|
||||
except exceptions.EOF:
|
||||
break
|
||||
|
||||
|
||||
class DockerSSHBox(Sandbox):
|
||||
instance_id: str
|
||||
container_image: str
|
||||
container_name_prefix = 'opendevin-sandbox-'
|
||||
container_name: str
|
||||
container: docker.models.containers.Container
|
||||
docker_client: docker.DockerClient
|
||||
|
||||
_ssh_password: str
|
||||
_ssh_port: int
|
||||
ssh: pxssh.pxssh | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: SandboxConfig,
|
||||
persist_sandbox: bool,
|
||||
workspace_mount_path: str | None,
|
||||
sandbox_workspace_dir: str,
|
||||
cache_dir: str,
|
||||
run_as_devin: bool,
|
||||
ssh_hostname: str = 'host.docker.internal',
|
||||
ssh_password: str | None = None,
|
||||
ssh_port: int = 22,
|
||||
sid: str | None = None,
|
||||
):
|
||||
self.config = config
|
||||
self.workspace_mount_path = workspace_mount_path
|
||||
self.sandbox_workspace_dir = sandbox_workspace_dir
|
||||
self.cache_dir = cache_dir
|
||||
self.use_host_network = config.use_host_network
|
||||
self.run_as_devin = run_as_devin
|
||||
logger.info(
|
||||
f'SSHBox is running as {"opendevin" if self.run_as_devin else "root"} user with USER_ID={config.user_id} in the sandbox'
|
||||
)
|
||||
# Initialize docker client. Throws an exception if Docker is not reachable.
|
||||
try:
|
||||
self.docker_client = docker.from_env()
|
||||
except Exception as ex:
|
||||
logger.exception(
|
||||
f'Error creating controller. Please check Docker is running and visit `{TROUBLESHOOTING_URL}` for more debugging information.',
|
||||
exc_info=False,
|
||||
)
|
||||
raise ex
|
||||
|
||||
if persist_sandbox:
|
||||
if not self.run_as_devin:
|
||||
raise Exception(
|
||||
'Persistent sandbox is currently designed for opendevin user only. Please set run_as_devin=True in your config.toml'
|
||||
)
|
||||
self.instance_id = 'persisted'
|
||||
else:
|
||||
self.instance_id = (sid or '') + str(uuid.uuid4())
|
||||
|
||||
self.container_image = get_od_sandbox_image(
|
||||
config.container_image, self.docker_client
|
||||
)
|
||||
self.container_name = self.container_name_prefix + self.instance_id
|
||||
|
||||
# set up random user password
|
||||
self.persist_sandbox = persist_sandbox
|
||||
self.ssh_hostname = ssh_hostname
|
||||
if persist_sandbox:
|
||||
if not ssh_password:
|
||||
raise ValueError('ssh_password is required for persistent sandbox')
|
||||
self._ssh_password = ssh_password
|
||||
self._ssh_port = ssh_port
|
||||
else:
|
||||
self._ssh_password = str(uuid.uuid4())
|
||||
self._ssh_port = find_available_tcp_port()
|
||||
try:
|
||||
docker.DockerClient().containers.get(self.container_name)
|
||||
self.is_initial_session = False
|
||||
except docker.errors.NotFound:
|
||||
self.is_initial_session = True
|
||||
logger.info('Detected initial session.')
|
||||
if not persist_sandbox or self.is_initial_session:
|
||||
logger.info('Creating new Docker container')
|
||||
n_tries = 5
|
||||
while n_tries > 0:
|
||||
try:
|
||||
self.restart_docker_container()
|
||||
break
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Failed to start Docker container, retrying...', exc_info=False
|
||||
)
|
||||
n_tries -= 1
|
||||
if n_tries == 0:
|
||||
raise e
|
||||
time.sleep(5)
|
||||
self.setup_user()
|
||||
else:
|
||||
self.container = self.docker_client.containers.get(self.container_name)
|
||||
logger.info('Using existing Docker container')
|
||||
self.start_docker_container()
|
||||
try:
|
||||
self.start_ssh_session()
|
||||
except Exception as e:
|
||||
self.close()
|
||||
raise e
|
||||
time.sleep(1)
|
||||
|
||||
# make sure /tmp always exists
|
||||
self.execute('mkdir -p /tmp')
|
||||
# set git config
|
||||
self.execute('git config --global user.name "OpenDevin"')
|
||||
self.execute('git config --global user.email "opendevin@all-hands.dev"')
|
||||
atexit.register(self.close)
|
||||
super().__init__(config)
|
||||
|
||||
def setup_user(self):
|
||||
time.sleep(2)
|
||||
# Make users sudoers passwordless
|
||||
# TODO(sandbox): add this line in the Dockerfile for next minor version of docker image
|
||||
exit_code, logs = self.container.exec_run(
|
||||
['/bin/bash', '-c', r"echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers"],
|
||||
workdir=self.sandbox_workspace_dir,
|
||||
environment=self._env,
|
||||
)
|
||||
if exit_code != 0:
|
||||
raise Exception(
|
||||
f'Failed to make all users passwordless sudoers in sandbox: {logs}'
|
||||
)
|
||||
|
||||
# Check if the opendevin user exists
|
||||
exit_code, logs = self.container.exec_run(
|
||||
['/bin/bash', '-c', 'id -u opendevin'],
|
||||
workdir=self.sandbox_workspace_dir,
|
||||
environment=self._env,
|
||||
)
|
||||
if exit_code == 0:
|
||||
# User exists, delete it
|
||||
exit_code, logs = self.container.exec_run(
|
||||
['/bin/bash', '-c', 'userdel -r opendevin'],
|
||||
workdir=self.sandbox_workspace_dir,
|
||||
environment=self._env,
|
||||
)
|
||||
if exit_code != 0:
|
||||
raise Exception(f'Failed to remove opendevin user in sandbox: {logs}')
|
||||
|
||||
if self.run_as_devin:
|
||||
# Create the opendevin user
|
||||
exit_code, logs = self.container.exec_run(
|
||||
[
|
||||
'/bin/bash',
|
||||
'-c',
|
||||
f'useradd -rm -d /home/opendevin -s /bin/bash -g root -G sudo -u {self.config.user_id} opendevin',
|
||||
],
|
||||
workdir=self.sandbox_workspace_dir,
|
||||
environment=self._env,
|
||||
)
|
||||
if exit_code != 0:
|
||||
raise Exception(f'Failed to create opendevin user in sandbox: {logs}')
|
||||
exit_code, logs = self.container.exec_run(
|
||||
[
|
||||
'/bin/bash',
|
||||
'-c',
|
||||
f"echo 'opendevin:{self._ssh_password}' | chpasswd",
|
||||
],
|
||||
workdir=self.sandbox_workspace_dir,
|
||||
environment=self._env,
|
||||
)
|
||||
if exit_code != 0:
|
||||
raise Exception(f'Failed to set password in sandbox: {logs}')
|
||||
|
||||
# chown the home directory
|
||||
exit_code, logs = self.container.exec_run(
|
||||
['/bin/bash', '-c', 'chown opendevin:root /home/opendevin'],
|
||||
workdir=self.sandbox_workspace_dir,
|
||||
environment=self._env,
|
||||
)
|
||||
if exit_code != 0:
|
||||
raise Exception(
|
||||
f'Failed to chown home directory for opendevin in sandbox: {logs}'
|
||||
)
|
||||
# check the miniforge3 directory exist
|
||||
exit_code, logs = self.container.exec_run(
|
||||
[
|
||||
'/bin/bash',
|
||||
'-c',
|
||||
'[ -d "/opendevin/miniforge3" ] && exit 0 || exit 1',
|
||||
],
|
||||
workdir=self.sandbox_workspace_dir,
|
||||
environment=self._env,
|
||||
)
|
||||
if exit_code != 0:
|
||||
if exit_code == 1:
|
||||
raise Exception(
|
||||
'OPENDEVIN_PYTHON_INTERPRETER is not usable. Please pull the latest Docker image: docker pull ghcr.io/opendevin/sandbox:main'
|
||||
)
|
||||
else:
|
||||
raise Exception(
|
||||
f'An error occurred while checking if miniforge3 directory exists: {logs}'
|
||||
)
|
||||
exit_code, logs = self.container.exec_run(
|
||||
[
|
||||
'/bin/bash',
|
||||
'-c',
|
||||
f'chown opendevin:root {self.sandbox_workspace_dir}',
|
||||
],
|
||||
workdir=self.sandbox_workspace_dir,
|
||||
environment=self._env,
|
||||
)
|
||||
if exit_code != 0:
|
||||
# This is not a fatal error, just a warning
|
||||
logger.warning(
|
||||
f'Failed to chown workspace directory for opendevin in sandbox: {logs}. But this should be fine if the {self.sandbox_workspace_dir=} is mounted by the app docker container.'
|
||||
)
|
||||
else:
|
||||
exit_code, logs = self.container.exec_run(
|
||||
# change password for root
|
||||
['/bin/bash', '-c', f"echo 'root:{self._ssh_password}' | chpasswd"],
|
||||
workdir=self.sandbox_workspace_dir,
|
||||
environment=self._env,
|
||||
)
|
||||
if exit_code != 0:
|
||||
raise Exception(f'Failed to set password for root in sandbox: {logs}')
|
||||
exit_code, logs = self.container.exec_run(
|
||||
['/bin/bash', '-c', "echo 'opendevin-sandbox' > /etc/hostname"],
|
||||
workdir=self.sandbox_workspace_dir,
|
||||
environment=self._env,
|
||||
)
|
||||
|
||||
# Use the retry decorator, with a maximum of 5 attempts and a fixed wait time of 5 seconds between attempts
|
||||
@retry(stop=stop_after_attempt(5), wait=wait_fixed(5))
|
||||
def __ssh_login(self):
|
||||
time.sleep(2)
|
||||
try:
|
||||
self.ssh = pxssh.pxssh(
|
||||
echo=False,
|
||||
timeout=self.config.timeout,
|
||||
encoding='utf-8',
|
||||
codec_errors='replace',
|
||||
)
|
||||
hostname = self.ssh_hostname
|
||||
username = 'opendevin' if self.run_as_devin else 'root'
|
||||
if self.persist_sandbox:
|
||||
password_msg = 'using your SSH password'
|
||||
else:
|
||||
password_msg = f"using the password '{self._ssh_password}'"
|
||||
logger.info('Connecting to SSH session...')
|
||||
hostname_to_log = hostname.replace('host.docker.internal', 'localhost')
|
||||
ssh_cmd = f'`ssh -v -p {self._ssh_port} {username}@{hostname_to_log}`'
|
||||
logger.info(
|
||||
f'You can debug the SSH connection by running: {ssh_cmd} {password_msg}'
|
||||
)
|
||||
self.ssh.login(hostname, username, self._ssh_password, port=self._ssh_port)
|
||||
logger.info('Connected to SSH session')
|
||||
except pxssh.ExceptionPxssh as e:
|
||||
logger.exception(
|
||||
'Failed to login to SSH session, retrying...', exc_info=False
|
||||
)
|
||||
raise e
|
||||
|
||||
def start_ssh_session(self):
|
||||
time.sleep(3)
|
||||
self.__ssh_login()
|
||||
assert self.ssh is not None
|
||||
|
||||
# Fix: https://github.com/pexpect/pexpect/issues/669
|
||||
self.ssh.sendline("bind 'set enable-bracketed-paste off'")
|
||||
self.ssh.prompt()
|
||||
time.sleep(1)
|
||||
|
||||
# cd to workspace
|
||||
self.ssh.sendline(f'cd {self.sandbox_workspace_dir}')
|
||||
self.ssh.prompt()
|
||||
|
||||
def get_exec_cmd(self, cmd: str) -> list[str]:
|
||||
if self.run_as_devin:
|
||||
return ['su', 'opendevin', '-c', cmd]
|
||||
else:
|
||||
return ['/bin/bash', '-c', cmd]
|
||||
|
||||
def _send_interrupt(
|
||||
self,
|
||||
cmd: str,
|
||||
prev_output: str = '',
|
||||
ignore_last_output: bool = False,
|
||||
) -> tuple[int, str]:
|
||||
assert self.ssh is not None
|
||||
logger.exception(
|
||||
f'Command "{cmd}" timed out, killing process...', exc_info=False
|
||||
)
|
||||
# send a SIGINT to the process
|
||||
self.ssh.sendintr()
|
||||
self.ssh.prompt()
|
||||
command_output = prev_output
|
||||
if not ignore_last_output:
|
||||
command_output += '\n' + self.ssh.before
|
||||
return (
|
||||
-1,
|
||||
f'Command: "{cmd}" timed out. Sent SIGINT to the process: {command_output}',
|
||||
)
|
||||
|
||||
def execute(
|
||||
self, cmd: str, stream: bool = False, timeout: int | None = None
|
||||
) -> tuple[int, str | CancellableStream]:
|
||||
assert self.ssh is not None
|
||||
timeout = timeout or self.config.timeout
|
||||
commands = split_bash_commands(cmd)
|
||||
if len(commands) > 1:
|
||||
all_output = ''
|
||||
for command in commands:
|
||||
exit_code, output = self.execute(command)
|
||||
if all_output:
|
||||
all_output += '\r\n'
|
||||
all_output += str(output)
|
||||
if exit_code != 0:
|
||||
return exit_code, all_output
|
||||
return 0, all_output
|
||||
|
||||
self.ssh.sendline(cmd)
|
||||
if stream:
|
||||
return 0, SSHExecCancellableStream(self.ssh, cmd, self.config.timeout)
|
||||
success = self.ssh.prompt(timeout=timeout)
|
||||
if not success:
|
||||
return self._send_interrupt(cmd)
|
||||
command_output = self.ssh.before
|
||||
|
||||
# once out, make sure that we have *every* output, we while loop until we get an empty output
|
||||
while True:
|
||||
self.ssh.sendline('\n')
|
||||
timeout_not_reached = self.ssh.prompt(timeout=1)
|
||||
if not timeout_not_reached:
|
||||
logger.debug('TIMEOUT REACHED')
|
||||
break
|
||||
output = self.ssh.before
|
||||
if isinstance(output, str) and output.strip() == '':
|
||||
break
|
||||
command_output += output
|
||||
command_output = command_output.removesuffix('\r\n')
|
||||
|
||||
# get the exit code
|
||||
self.ssh.sendline('echo $?')
|
||||
self.ssh.prompt()
|
||||
exit_code_str = self.ssh.before.strip()
|
||||
_start_time = time.time()
|
||||
while not exit_code_str:
|
||||
self.ssh.prompt(timeout=1)
|
||||
exit_code_str = self.ssh.before.strip()
|
||||
if time.time() - _start_time > timeout:
|
||||
return self._send_interrupt(
|
||||
cmd, command_output, ignore_last_output=True
|
||||
)
|
||||
cleaned_exit_code_str = exit_code_str.replace('echo $?', '').strip().split()[0]
|
||||
|
||||
try:
|
||||
exit_code = int(cleaned_exit_code_str)
|
||||
except ValueError:
|
||||
logger.error(f'Invalid exit code: {cleaned_exit_code_str}')
|
||||
# Handle the invalid exit code appropriately (e.g., raise an exception or set a default value)
|
||||
exit_code = -1 # or some other appropriate default value
|
||||
|
||||
return exit_code, command_output
|
||||
|
||||
def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False):
|
||||
if not os.path.exists(host_src):
|
||||
raise FileNotFoundError(f'Source file {host_src} does not exist')
|
||||
|
||||
# mkdir -p sandbox_dest if it doesn't exist
|
||||
exit_code, logs = self.container.exec_run(
|
||||
['/bin/bash', '-c', f'mkdir -p {sandbox_dest}'],
|
||||
workdir=self.sandbox_workspace_dir,
|
||||
environment=self._env,
|
||||
)
|
||||
if exit_code != 0:
|
||||
raise Exception(
|
||||
f'Failed to create directory {sandbox_dest} in sandbox: {logs}'
|
||||
)
|
||||
|
||||
# use temp directory to store the tar file to avoid
|
||||
# conflict of filename when running multi-processes
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
if recursive:
|
||||
assert os.path.isdir(
|
||||
host_src
|
||||
), 'Source must be a directory when recursive is True'
|
||||
files = glob(host_src + '/**/*', recursive=True)
|
||||
srcname = os.path.basename(host_src)
|
||||
tar_filename = os.path.join(tmp_dir, srcname + '.tar')
|
||||
with tarfile.open(tar_filename, mode='w') as tar:
|
||||
for file in files:
|
||||
tar.add(
|
||||
file,
|
||||
arcname=os.path.relpath(file, os.path.dirname(host_src)),
|
||||
)
|
||||
else:
|
||||
assert os.path.isfile(
|
||||
host_src
|
||||
), 'Source must be a file when recursive is False'
|
||||
srcname = os.path.basename(host_src)
|
||||
tar_filename = os.path.join(tmp_dir, srcname + '.tar')
|
||||
with tarfile.open(tar_filename, mode='w') as tar:
|
||||
tar.add(host_src, arcname=srcname)
|
||||
|
||||
with open(tar_filename, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
self.container.put_archive(sandbox_dest, data)
|
||||
|
||||
def start_docker_container(self):
|
||||
try:
|
||||
container = self.docker_client.containers.get(self.container_name)
|
||||
logger.info('Container status: %s', container.status)
|
||||
if container.status != 'running':
|
||||
container.start()
|
||||
logger.info('Container started')
|
||||
elapsed = 0
|
||||
while container.status != 'running':
|
||||
time.sleep(1)
|
||||
elapsed += 1
|
||||
if elapsed > self.config.timeout:
|
||||
break
|
||||
container = self.docker_client.containers.get(self.container_name)
|
||||
except Exception:
|
||||
logger.exception('Failed to start container')
|
||||
|
||||
def remove_docker_container(self):
|
||||
try:
|
||||
container = self.docker_client.containers.get(self.container_name)
|
||||
container.stop()
|
||||
logger.info('Container stopped')
|
||||
container.remove()
|
||||
logger.info('Container removed')
|
||||
elapsed = 0
|
||||
while container.status != 'exited':
|
||||
time.sleep(1)
|
||||
elapsed += 1
|
||||
if elapsed > self.config.timeout:
|
||||
break
|
||||
container = self.docker_client.containers.get(self.container_name)
|
||||
except docker.errors.NotFound:
|
||||
pass
|
||||
|
||||
def get_working_directory(self):
|
||||
exit_code, result = self.execute('pwd')
|
||||
if exit_code != 0:
|
||||
raise Exception('Failed to get working directory')
|
||||
return str(result).strip()
|
||||
|
||||
def is_container_running(self):
|
||||
try:
|
||||
container = self.docker_client.containers.get(self.container_name)
|
||||
if container.status == 'running':
|
||||
self.container = container
|
||||
return True
|
||||
return False
|
||||
except docker.errors.NotFound:
|
||||
return False
|
||||
|
||||
@property
|
||||
def volumes(self):
|
||||
mount_volumes = {
|
||||
# mount cache directory to /home/opendevin/.cache for pip cache reuse
|
||||
self.cache_dir: {
|
||||
'bind': (
|
||||
'/home/opendevin/.cache' if self.run_as_devin else '/root/.cache'
|
||||
),
|
||||
'mode': 'rw',
|
||||
},
|
||||
}
|
||||
if self.workspace_mount_path is not None:
|
||||
mount_volumes[self.workspace_mount_path] = {
|
||||
'bind': self.sandbox_workspace_dir,
|
||||
'mode': 'rw',
|
||||
}
|
||||
return mount_volumes
|
||||
|
||||
def restart_docker_container(self):
|
||||
try:
|
||||
self.remove_docker_container()
|
||||
except docker.errors.DockerException as ex:
|
||||
logger.exception('Failed to remove container', exc_info=False)
|
||||
raise ex
|
||||
|
||||
try:
|
||||
network_kwargs: dict[str, str | dict[str, int]] = {}
|
||||
if self.use_host_network:
|
||||
network_kwargs['network_mode'] = 'host'
|
||||
else:
|
||||
# FIXME: This is a temporary workaround for Windows where host network mode has bugs.
|
||||
# FIXME: Docker Desktop for Mac OS has experimental support for host network mode
|
||||
network_kwargs['ports'] = {f'{self._ssh_port}/tcp': self._ssh_port}
|
||||
logger.warning(
|
||||
(
|
||||
'Using port forwarding till the enable host network mode of Docker is out of experimental mode.'
|
||||
'Check the 897th issue on https://github.com/OpenDevin/OpenDevin/issues/ for more information.'
|
||||
)
|
||||
)
|
||||
|
||||
# start the container
|
||||
logger.info(f'Mounting volumes: {self.volumes}')
|
||||
self.container = self.docker_client.containers.run(
|
||||
self.container_image,
|
||||
# allow root login
|
||||
command=f"/usr/sbin/sshd -D -p {self._ssh_port} -o 'PermitRootLogin=yes'",
|
||||
**network_kwargs,
|
||||
working_dir=self.sandbox_workspace_dir,
|
||||
name=self.container_name,
|
||||
detach=True,
|
||||
volumes=self.volumes,
|
||||
)
|
||||
logger.info('Container started')
|
||||
except Exception as ex:
|
||||
logger.exception('Failed to start container: ' + str(ex), exc_info=False)
|
||||
raise ex
|
||||
|
||||
# wait for container to be ready
|
||||
elapsed = 0
|
||||
while self.container.status != 'running':
|
||||
if self.container.status == 'exited':
|
||||
logger.info('container exited')
|
||||
logger.info('container logs:')
|
||||
logger.info(self.container.logs())
|
||||
break
|
||||
time.sleep(1)
|
||||
elapsed += 1
|
||||
self.container = self.docker_client.containers.get(self.container_name)
|
||||
logger.info(
|
||||
f'waiting for container to start: {elapsed}, container status: {self.container.status}'
|
||||
)
|
||||
if elapsed > self.config.timeout:
|
||||
break
|
||||
if self.container.status != 'running':
|
||||
raise Exception('Failed to start container')
|
||||
|
||||
# clean up the container, cannot do it in __del__ because the python interpreter is already shutting down
|
||||
def close(self):
|
||||
containers = self.docker_client.containers.list(all=True)
|
||||
for container in containers:
|
||||
try:
|
||||
if container.name.startswith(self.container_name):
|
||||
if self.persist_sandbox:
|
||||
container.stop()
|
||||
else:
|
||||
# only remove the container we created
|
||||
# otherwise all other containers with the same prefix will be removed
|
||||
# which will mess up with parallel evaluation
|
||||
container.remove(force=True)
|
||||
except docker.errors.NotFound:
|
||||
pass
|
||||
self.docker_client.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
ssh_box = DockerSSHBox(
|
||||
config=SandboxConfig(),
|
||||
run_as_devin=False,
|
||||
workspace_mount_path='/path/to/workspace',
|
||||
cache_dir='/path/to/cache',
|
||||
sandbox_workspace_dir='/sandbox',
|
||||
persist_sandbox=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Failed to start Docker container: %s', e)
|
||||
sys.exit(1)
|
||||
|
||||
logger.info(
|
||||
"Interactive Docker container started. Type 'exit' or use Ctrl+C to exit."
|
||||
)
|
||||
|
||||
# Initialize required plugins
|
||||
plugins: list[PluginRequirement] = [AgentSkillsRequirement(), JupyterRequirement()]
|
||||
ssh_box.init_plugins(plugins)
|
||||
logger.info(
|
||||
'--- AgentSkills COMMAND DOCUMENTATION ---\n'
|
||||
f'{AgentSkillsRequirement().documentation}\n'
|
||||
'---'
|
||||
)
|
||||
|
||||
sys.stdout.flush()
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
user_input = input('$ ')
|
||||
except EOFError:
|
||||
logger.info('Exiting...')
|
||||
break
|
||||
if user_input.lower() == 'exit':
|
||||
logger.info('Exiting...')
|
||||
break
|
||||
exit_code, output = ssh_box.execute(user_input)
|
||||
logger.info('exit code: %d', exit_code)
|
||||
logger.info(output)
|
||||
sys.stdout.flush()
|
||||
except KeyboardInterrupt:
|
||||
logger.info('Exiting...')
|
||||
ssh_box.close()
|
||||
@ -12,14 +12,14 @@ from opendevin.events.observation import (
|
||||
from opendevin.events.stream import EventStream
|
||||
from opendevin.runtime import Sandbox
|
||||
from opendevin.runtime.plugins import PluginRequirement
|
||||
from opendevin.runtime.server.files import insert_lines, read_lines
|
||||
from opendevin.runtime.server.runtime import ServerRuntime
|
||||
from opendevin.runtime.runtime import Runtime
|
||||
|
||||
from ..utils.files import insert_lines, read_lines
|
||||
from .filestore import E2BFileStore
|
||||
from .sandbox import E2BSandbox
|
||||
|
||||
|
||||
class E2BRuntime(ServerRuntime):
|
||||
class E2BRuntime(Runtime):
|
||||
def __init__(
|
||||
self,
|
||||
config: AppConfig,
|
||||
@ -28,7 +28,9 @@ class E2BRuntime(ServerRuntime):
|
||||
plugins: list[PluginRequirement] | None = None,
|
||||
sandbox: Sandbox | None = None,
|
||||
):
|
||||
super().__init__(config, event_stream, sid, plugins, sandbox)
|
||||
super().__init__(config, event_stream, sid, plugins)
|
||||
if sandbox is None:
|
||||
self.sandbox = E2BSandbox()
|
||||
if not isinstance(self.sandbox, E2BSandbox):
|
||||
raise ValueError('E2BRuntime requires an E2BSandbox')
|
||||
self.file_store = E2BFileStore(self.sandbox.filesystem)
|
||||
|
||||
@ -4,7 +4,6 @@ import copy
|
||||
import json
|
||||
import os
|
||||
from abc import abstractmethod
|
||||
from typing import Any, Optional
|
||||
|
||||
from opendevin.core.config import AppConfig, SandboxConfig
|
||||
from opendevin.core.logger import opendevin_logger as logger
|
||||
@ -29,7 +28,6 @@ from opendevin.events.observation import (
|
||||
)
|
||||
from opendevin.events.serialization.action import ACTION_TYPE_TO_CLASS
|
||||
from opendevin.runtime.plugins import JupyterRequirement, PluginRequirement
|
||||
from opendevin.runtime.tools import RuntimeTool
|
||||
|
||||
|
||||
def _default_env_vars(sandbox_config: SandboxConfig) -> dict[str, str]:
|
||||
@ -51,6 +49,7 @@ class Runtime:
|
||||
"""
|
||||
|
||||
sid: str
|
||||
config: AppConfig
|
||||
DEFAULT_ENV_VARS: dict[str, str]
|
||||
|
||||
def __init__(
|
||||
@ -99,18 +98,6 @@ class Runtime:
|
||||
else:
|
||||
loop.run_until_complete(self.close())
|
||||
|
||||
# ====================================================================
|
||||
# Methods we plan to deprecate when we move to new EventStreamRuntime
|
||||
# ====================================================================
|
||||
|
||||
def init_runtime_tools(
|
||||
self,
|
||||
runtime_tools: list[RuntimeTool],
|
||||
runtime_tools_config: Optional[dict[RuntimeTool, Any]] = None,
|
||||
) -> None:
|
||||
# TODO: deprecate this method when we move to the new EventStreamRuntime
|
||||
raise NotImplementedError('This method is not implemented in the base class.')
|
||||
|
||||
# ====================================================================
|
||||
|
||||
async def add_env_vars(self, env_vars: dict[str, str]) -> None:
|
||||
@ -179,12 +166,8 @@ class Runtime:
|
||||
observation = await getattr(self, action_type)(action)
|
||||
return observation
|
||||
|
||||
@abstractmethod
|
||||
async def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False):
|
||||
raise NotImplementedError('This method is not implemented in the base class.')
|
||||
|
||||
# ====================================================================
|
||||
# Implement these methods in the subclass
|
||||
# Action execution
|
||||
# ====================================================================
|
||||
|
||||
@abstractmethod
|
||||
@ -210,3 +193,19 @@ class Runtime:
|
||||
@abstractmethod
|
||||
async def browse_interactive(self, action: BrowseInteractiveAction) -> Observation:
|
||||
pass
|
||||
|
||||
# ====================================================================
|
||||
# File operations
|
||||
# ====================================================================
|
||||
|
||||
@abstractmethod
|
||||
async def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False):
|
||||
raise NotImplementedError('This method is not implemented in the base class.')
|
||||
|
||||
@abstractmethod
|
||||
async 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).
|
||||
"""
|
||||
raise NotImplementedError('This method is not implemented in the base class.')
|
||||
|
||||
@ -1,234 +0,0 @@
|
||||
from typing import Any, Optional
|
||||
|
||||
from opendevin.core.config import AppConfig
|
||||
from opendevin.core.exceptions import BrowserInitException
|
||||
from opendevin.core.logger import opendevin_logger as logger
|
||||
from opendevin.events.action import (
|
||||
BrowseInteractiveAction,
|
||||
BrowseURLAction,
|
||||
CmdRunAction,
|
||||
FileReadAction,
|
||||
FileWriteAction,
|
||||
IPythonRunCellAction,
|
||||
)
|
||||
from opendevin.events.observation import (
|
||||
CmdOutputObservation,
|
||||
ErrorObservation,
|
||||
IPythonRunCellObservation,
|
||||
Observation,
|
||||
)
|
||||
from opendevin.events.stream import EventStream
|
||||
from opendevin.runtime import (
|
||||
DockerSSHBox,
|
||||
E2BBox,
|
||||
LocalBox,
|
||||
Sandbox,
|
||||
)
|
||||
from opendevin.runtime.browser.browser_env import BrowserEnv
|
||||
from opendevin.runtime.plugins import JupyterRequirement, PluginRequirement
|
||||
from opendevin.runtime.runtime import Runtime
|
||||
from opendevin.runtime.tools import RuntimeTool
|
||||
|
||||
from ..browser import browse
|
||||
from .files import read_file, write_file
|
||||
|
||||
|
||||
class ServerRuntime(Runtime):
|
||||
def __init__(
|
||||
self,
|
||||
config: AppConfig,
|
||||
event_stream: EventStream,
|
||||
sid: str = 'default',
|
||||
plugins: list[PluginRequirement] | None = None,
|
||||
sandbox: Sandbox | None = None,
|
||||
):
|
||||
super().__init__(config, event_stream, sid, plugins)
|
||||
if sandbox is None:
|
||||
self.sandbox = self.create_sandbox(sid, config.sandbox.box_type)
|
||||
self._is_external_sandbox = False
|
||||
else:
|
||||
self.sandbox = sandbox
|
||||
self._is_external_sandbox = True
|
||||
self.browser: BrowserEnv | None = None
|
||||
logger.debug(f'ServerRuntime `{sid}` config:\n{self.config}')
|
||||
|
||||
def create_sandbox(self, sid: str = 'default', box_type: str = 'ssh') -> Sandbox:
|
||||
if box_type == 'local':
|
||||
return LocalBox(
|
||||
config=self.config.sandbox, workspace_base=self.config.workspace_base
|
||||
)
|
||||
elif box_type == 'ssh':
|
||||
return DockerSSHBox(
|
||||
config=self.config.sandbox,
|
||||
persist_sandbox=self.config.persist_sandbox,
|
||||
workspace_mount_path=self.config.workspace_mount_path,
|
||||
sandbox_workspace_dir=self.config.workspace_mount_path_in_sandbox,
|
||||
cache_dir=self.config.cache_dir,
|
||||
run_as_devin=self.config.run_as_devin,
|
||||
ssh_hostname=self.config.ssh_hostname,
|
||||
ssh_password=self.config.ssh_password,
|
||||
ssh_port=self.config.ssh_port,
|
||||
sid=sid,
|
||||
)
|
||||
elif box_type == 'e2b':
|
||||
return E2BBox(
|
||||
config=self.config.sandbox,
|
||||
e2b_api_key=self.config.e2b_api_key,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f'Invalid sandbox type: {box_type}')
|
||||
|
||||
async def ainit(self, env_vars: dict[str, str] | None = None):
|
||||
# init sandbox plugins
|
||||
self.sandbox.init_plugins(self.plugins)
|
||||
|
||||
# MUST call super().ainit() to initialize both default env vars
|
||||
# AND the ones in env vars!
|
||||
await super().ainit(env_vars)
|
||||
|
||||
if any(isinstance(plugin, JupyterRequirement) for plugin in self.plugins):
|
||||
obs = await self.run_ipython(
|
||||
IPythonRunCellAction(
|
||||
code=f'import os; os.chdir("{self.config.workspace_mount_path_in_sandbox}")'
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
f'Switch to working directory {self.config.workspace_mount_path_in_sandbox} in IPython. Output: {obs.content}'
|
||||
)
|
||||
|
||||
async def close(self):
|
||||
if hasattr(self, '_is_external_sandbox') and not self._is_external_sandbox:
|
||||
self.sandbox.close()
|
||||
if hasattr(self, 'browser') and self.browser is not None:
|
||||
self.browser.close()
|
||||
|
||||
def init_runtime_tools(
|
||||
self,
|
||||
runtime_tools: list[RuntimeTool],
|
||||
runtime_tools_config: Optional[dict[RuntimeTool, Any]] = None,
|
||||
) -> None:
|
||||
# if browser in runtime_tools, init it
|
||||
if RuntimeTool.BROWSER in runtime_tools:
|
||||
if runtime_tools_config is None:
|
||||
runtime_tools_config = {}
|
||||
browser_env_config = runtime_tools_config.get(RuntimeTool.BROWSER, {})
|
||||
try:
|
||||
self.browser = BrowserEnv(**browser_env_config)
|
||||
except BrowserInitException:
|
||||
logger.warn(
|
||||
'Failed to start browser environment, web browsing functionality will not work'
|
||||
)
|
||||
|
||||
async def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False):
|
||||
self.sandbox.copy_to(host_src, sandbox_dest, recursive)
|
||||
|
||||
async def run(self, action: CmdRunAction) -> Observation:
|
||||
return self._run_command(action.command)
|
||||
|
||||
async def run_ipython(self, action: IPythonRunCellAction) -> Observation:
|
||||
self._run_command(
|
||||
f"cat > /tmp/opendevin_jupyter_temp.py <<'EOL'\n{action.code}\nEOL"
|
||||
)
|
||||
|
||||
# run the code
|
||||
obs = self._run_command('cat /tmp/opendevin_jupyter_temp.py | execute_cli')
|
||||
output = obs.content
|
||||
if 'pip install' in action.code:
|
||||
print(output)
|
||||
package_names = action.code.split(' ', 2)[-1]
|
||||
is_single_package = ' ' not in package_names
|
||||
|
||||
if 'Successfully installed' in output:
|
||||
restart_kernel = 'import IPython\nIPython.Application.instance().kernel.do_shutdown(True)'
|
||||
if (
|
||||
'Note: you may need to restart the kernel to use updated packages.'
|
||||
in output
|
||||
):
|
||||
self._run_command(
|
||||
(
|
||||
"cat > /tmp/opendevin_jupyter_temp.py <<'EOL'\n"
|
||||
f'{restart_kernel}\n'
|
||||
'EOL'
|
||||
)
|
||||
)
|
||||
obs = self._run_command(
|
||||
'cat /tmp/opendevin_jupyter_temp.py | execute_cli'
|
||||
)
|
||||
output = '[Package installed successfully]'
|
||||
if "{'status': 'ok', 'restart': True}" != obs.content.strip():
|
||||
print(obs.content)
|
||||
output += (
|
||||
'\n[But failed to restart the kernel to load the package]'
|
||||
)
|
||||
else:
|
||||
output += (
|
||||
'\n[Kernel restarted successfully to load the package]'
|
||||
)
|
||||
|
||||
# re-init the kernel after restart
|
||||
if action.kernel_init_code:
|
||||
self._run_command(
|
||||
(
|
||||
f"cat > /tmp/opendevin_jupyter_init.py <<'EOL'\n"
|
||||
f'{action.kernel_init_code}\n'
|
||||
'EOL'
|
||||
),
|
||||
)
|
||||
obs = self._run_command(
|
||||
'cat /tmp/opendevin_jupyter_init.py | execute_cli',
|
||||
)
|
||||
elif (
|
||||
is_single_package
|
||||
and f'Requirement already satisfied: {package_names}' in output
|
||||
):
|
||||
output = '[Package already installed]'
|
||||
return IPythonRunCellObservation(content=output, code=action.code)
|
||||
|
||||
async def read(self, action: FileReadAction) -> Observation:
|
||||
working_dir = self.sandbox.get_working_directory()
|
||||
return await read_file(
|
||||
action.path,
|
||||
working_dir,
|
||||
self.config.workspace_base,
|
||||
self.config.workspace_mount_path_in_sandbox,
|
||||
action.start,
|
||||
action.end,
|
||||
)
|
||||
|
||||
async def write(self, action: FileWriteAction) -> Observation:
|
||||
working_dir = self.sandbox.get_working_directory()
|
||||
return await write_file(
|
||||
action.path,
|
||||
working_dir,
|
||||
self.config.workspace_base,
|
||||
self.config.workspace_mount_path_in_sandbox,
|
||||
action.content,
|
||||
action.start,
|
||||
action.end,
|
||||
)
|
||||
|
||||
async def browse(self, action: BrowseURLAction) -> Observation:
|
||||
return await browse(action, self.browser)
|
||||
|
||||
async def browse_interactive(self, action: BrowseInteractiveAction) -> Observation:
|
||||
return await browse(action, self.browser)
|
||||
|
||||
def _run_command(self, command: str) -> Observation:
|
||||
try:
|
||||
exit_code, output = self.sandbox.execute(command)
|
||||
if 'pip install' in command:
|
||||
package_names = command.split(' ', 2)[-1]
|
||||
is_single_package = ' ' not in package_names
|
||||
print(output)
|
||||
if 'Successfully installed' in output:
|
||||
output = '[Package installed successfully]'
|
||||
elif (
|
||||
is_single_package
|
||||
and f'Requirement already satisfied: {package_names}' in output
|
||||
):
|
||||
output = '[Package already installed]'
|
||||
return CmdOutputObservation(
|
||||
command_id=-1, content=str(output), command=command, exit_code=exit_code
|
||||
)
|
||||
except UnicodeDecodeError:
|
||||
return ErrorObservation('Command output could not be decoded as utf-8')
|
||||
@ -1,11 +1,10 @@
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import uuid
|
||||
import warnings
|
||||
|
||||
import requests
|
||||
from pathspec import PathSpec
|
||||
from pathspec.patterns import GitWildMatchPattern
|
||||
|
||||
from opendevin.server.data_models.feedback import FeedbackDataModel, store_feedback
|
||||
from opendevin.storage import get_file_store
|
||||
@ -33,13 +32,22 @@ from opendevin.controller.agent import Agent
|
||||
from opendevin.core.config import LLMConfig, load_app_config
|
||||
from opendevin.core.logger import opendevin_logger as logger
|
||||
from opendevin.core.schema import AgentState # Add this import
|
||||
from opendevin.events.action import ChangeAgentStateAction, NullAction
|
||||
from opendevin.events.action import (
|
||||
ChangeAgentStateAction,
|
||||
FileReadAction,
|
||||
FileWriteAction,
|
||||
NullAction,
|
||||
)
|
||||
from opendevin.events.observation import (
|
||||
AgentStateChangedObservation,
|
||||
ErrorObservation,
|
||||
FileReadObservation,
|
||||
FileWriteObservation,
|
||||
NullObservation,
|
||||
)
|
||||
from opendevin.events.serialization import event_to_dict
|
||||
from opendevin.llm import bedrock
|
||||
from opendevin.runtime.runtime import Runtime
|
||||
from opendevin.server.auth import get_sid_from_token, sign_token
|
||||
from opendevin.server.session import SessionManager
|
||||
|
||||
@ -355,7 +363,7 @@ async def get_agents():
|
||||
|
||||
|
||||
@app.get('/api/list-files')
|
||||
def list_files(request: Request, path: str = '/'):
|
||||
async def list_files(request: Request, path: str | None = None):
|
||||
"""List files in the specified path.
|
||||
|
||||
This function retrieves a list of files from the agent's runtime file store,
|
||||
@ -368,7 +376,7 @@ def list_files(request: Request, path: str = '/'):
|
||||
|
||||
Args:
|
||||
request (Request): The incoming request object.
|
||||
path (str, optional): The path to list files from. Defaults to '/'.
|
||||
path (str, optional): The path to list files from. Defaults to None.
|
||||
|
||||
Returns:
|
||||
list: A list of file names in the specified path.
|
||||
@ -381,90 +389,13 @@ def list_files(request: Request, path: str = '/'):
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': 'Runtime not yet initialized'},
|
||||
)
|
||||
|
||||
try:
|
||||
# Get the full path of the requested directory
|
||||
full_path = (
|
||||
request.state.session.agent_session.runtime.file_store.get_full_path(path)
|
||||
)
|
||||
|
||||
# Check if the directory exists
|
||||
if not os.path.exists(full_path) or not os.path.isdir(full_path):
|
||||
return []
|
||||
|
||||
# Check if .gitignore exists
|
||||
gitignore_path = os.path.join(full_path, '.gitignore')
|
||||
if os.path.exists(gitignore_path):
|
||||
# Use PathSpec to parse .gitignore
|
||||
with open(gitignore_path, 'r') as f:
|
||||
spec = PathSpec.from_lines(GitWildMatchPattern, f.readlines())
|
||||
else:
|
||||
# Fallback to default exclude list if .gitignore doesn't exist
|
||||
default_exclude = [
|
||||
'.git',
|
||||
'.DS_Store',
|
||||
'.svn',
|
||||
'.hg',
|
||||
'.idea',
|
||||
'.vscode',
|
||||
'.settings',
|
||||
'.pytest_cache',
|
||||
'__pycache__',
|
||||
'node_modules',
|
||||
'vendor',
|
||||
'build',
|
||||
'dist',
|
||||
'bin',
|
||||
'logs',
|
||||
'log',
|
||||
'tmp',
|
||||
'temp',
|
||||
'coverage',
|
||||
'venv',
|
||||
'env',
|
||||
]
|
||||
spec = PathSpec.from_lines(GitWildMatchPattern, default_exclude)
|
||||
|
||||
entries = request.state.session.agent_session.runtime.file_store.list(path)
|
||||
|
||||
# Filter entries using PathSpec
|
||||
filtered_entries = [
|
||||
entry
|
||||
for entry in entries
|
||||
if not spec.match_file(os.path.relpath(entry, str(full_path)))
|
||||
]
|
||||
|
||||
# Separate directories and files
|
||||
directories = []
|
||||
files = []
|
||||
for entry in filtered_entries:
|
||||
# Remove leading slash and any parent directory components
|
||||
entry_relative = entry.lstrip('/').split('/')[-1]
|
||||
|
||||
# Construct the full path by joining the base path with the relative entry path
|
||||
full_entry_path = os.path.join(full_path, entry_relative)
|
||||
if os.path.exists(full_entry_path):
|
||||
is_dir = os.path.isdir(full_entry_path)
|
||||
if is_dir:
|
||||
directories.append(entry)
|
||||
else:
|
||||
files.append(entry)
|
||||
|
||||
# Sort directories and files separately
|
||||
directories.sort(key=lambda s: s.lower())
|
||||
files.sort(key=lambda s: s.lower())
|
||||
|
||||
# Combine sorted directories and files
|
||||
sorted_entries = directories + files
|
||||
return sorted_entries
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error listing files: {e}', exc_info=True)
|
||||
return []
|
||||
runtime: Runtime = request.state.session.agent_session.runtime
|
||||
file_list = await runtime.list_files(path)
|
||||
return file_list
|
||||
|
||||
|
||||
@app.get('/api/select-file')
|
||||
def select_file(file: str, request: Request):
|
||||
async def select_file(file: str, request: Request):
|
||||
"""Retrieve the content of a specified file.
|
||||
|
||||
To select a file:
|
||||
@ -474,6 +405,7 @@ def select_file(file: str, request: Request):
|
||||
|
||||
Args:
|
||||
file (str): The path of the file to be retrieved.
|
||||
Expect path to be absolute inside the runtime.
|
||||
request (Request): The incoming request object.
|
||||
|
||||
Returns:
|
||||
@ -482,16 +414,27 @@ def select_file(file: str, request: Request):
|
||||
Raises:
|
||||
HTTPException: If there's an error opening the file.
|
||||
"""
|
||||
try:
|
||||
content = request.state.session.agent_session.runtime.file_store.read(file)
|
||||
except Exception as e:
|
||||
logger.error(f'Error opening file {file}: {e}', exc_info=False)
|
||||
error_msg = f'Error opening file: {e}'
|
||||
runtime: Runtime = request.state.session.agent_session.runtime
|
||||
|
||||
# convert file to an absolute path inside the runtime
|
||||
if not os.path.isabs(file):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={'error': 'File path must be absolute'},
|
||||
)
|
||||
|
||||
read_action = FileReadAction(file)
|
||||
observation = await runtime.run_action(read_action)
|
||||
|
||||
if isinstance(observation, FileReadObservation):
|
||||
content = observation.content
|
||||
return {'code': content}
|
||||
elif isinstance(observation, ErrorObservation):
|
||||
logger.error(f'Error opening file {file}: {observation}', exc_info=False)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={'error': error_msg},
|
||||
content={'error': f'Error opening file: {observation}'},
|
||||
)
|
||||
return {'code': content}
|
||||
|
||||
|
||||
def sanitize_filename(filename):
|
||||
@ -552,9 +495,17 @@ async def upload_file(request: Request, files: list[UploadFile]):
|
||||
)
|
||||
continue
|
||||
|
||||
request.state.session.agent_session.runtime.file_store.write(
|
||||
safe_filename, file_contents
|
||||
)
|
||||
# copy the file to the runtime
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_file_path = os.path.join(tmp_dir, safe_filename)
|
||||
with open(tmp_file_path, 'wb') as tmp_file:
|
||||
tmp_file.write(file_contents)
|
||||
tmp_file.flush()
|
||||
|
||||
runtime: Runtime = request.state.session.agent_session.runtime
|
||||
await runtime.copy_to(
|
||||
tmp_file_path, runtime.config.workspace_mount_path_in_sandbox
|
||||
)
|
||||
uploaded_files.append(safe_filename)
|
||||
|
||||
response_content = {
|
||||
@ -709,13 +660,32 @@ async def save_file(request: Request):
|
||||
if not file_path or content is None:
|
||||
raise HTTPException(status_code=400, detail='Missing filePath or content')
|
||||
|
||||
# Save the file to the agent's runtime file store
|
||||
request.state.session.agent_session.runtime.file_store.write(file_path, content)
|
||||
# Make sure file_path is abs
|
||||
if not os.path.isabs(file_path):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={'error': 'File path must be absolute'},
|
||||
)
|
||||
|
||||
# Return a success response
|
||||
return JSONResponse(
|
||||
status_code=200, content={'message': 'File saved successfully'}
|
||||
)
|
||||
# Save the file to the agent's runtime file store
|
||||
runtime: Runtime = request.state.session.agent_session.runtime
|
||||
write_action = FileWriteAction(file_path, content)
|
||||
observation = await runtime.run_action(write_action)
|
||||
|
||||
if isinstance(observation, FileWriteObservation):
|
||||
return JSONResponse(
|
||||
status_code=200, content={'message': 'File saved successfully'}
|
||||
)
|
||||
elif isinstance(observation, ErrorObservation):
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={'error': f'Failed to save file: {observation}'},
|
||||
)
|
||||
else:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={'error': f'Unexpected observation: {observation}'},
|
||||
)
|
||||
except Exception as e:
|
||||
# Log the error and return a 500 response
|
||||
logger.error(f'Error saving file: {e}', exc_info=True)
|
||||
|
||||
@ -1,15 +1,13 @@
|
||||
from typing import Optional
|
||||
|
||||
from agenthub.codeact_agent.codeact_agent import CodeActAgent
|
||||
from opendevin.controller import AgentController
|
||||
from opendevin.controller.agent import Agent
|
||||
from opendevin.controller.state.state import State
|
||||
from opendevin.core.config import AppConfig, LLMConfig
|
||||
from opendevin.core.logger import opendevin_logger as logger
|
||||
from opendevin.events.stream import EventStream
|
||||
from opendevin.runtime import DockerSSHBox, get_runtime_cls
|
||||
from opendevin.runtime import get_runtime_cls
|
||||
from opendevin.runtime.runtime import Runtime
|
||||
from opendevin.runtime.server.runtime import ServerRuntime
|
||||
from opendevin.storage.files import FileStore
|
||||
|
||||
|
||||
@ -22,6 +20,7 @@ class AgentSession:
|
||||
|
||||
sid: str
|
||||
event_stream: EventStream
|
||||
file_store: FileStore
|
||||
controller: Optional[AgentController] = None
|
||||
runtime: Optional[Runtime] = None
|
||||
_closed: bool = False
|
||||
@ -101,16 +100,6 @@ class AgentSession:
|
||||
raise Exception('Runtime must be initialized before the agent controller')
|
||||
|
||||
logger.info(f'Creating agent {agent.name} using LLM {agent.llm.config.model}')
|
||||
if isinstance(agent, CodeActAgent):
|
||||
if not self.runtime or not (
|
||||
isinstance(self.runtime, ServerRuntime)
|
||||
and isinstance(self.runtime.sandbox, DockerSSHBox)
|
||||
):
|
||||
logger.warning(
|
||||
'CodeActAgent requires DockerSSHBox as sandbox! Using other sandbox that are not stateful'
|
||||
' LocalBox will not work properly.'
|
||||
)
|
||||
self.runtime.init_runtime_tools(agent.runtime_tools)
|
||||
|
||||
self.controller = AgentController(
|
||||
sid=self.sid,
|
||||
|
||||
@ -48,15 +48,12 @@ where `conftest.py` defines the infrastructure needed to load real-world LLM pro
|
||||
and responses for mocking purpose. Prompts and responses generated during real runs
|
||||
of agents with real LLMs are stored under `mock/AgentName/TestName` folders.
|
||||
|
||||
**Note:** Set PERSIST_SANDBOX=false to use a clean sandbox for each test.
|
||||
|
||||
## Run Integration Tests
|
||||
|
||||
Take a look at `ghcr.yml` (in the `.github/workflow` folder) to learn
|
||||
how integration tests are launched in a CI environment.
|
||||
|
||||
We currently have two runtime: `ServerRuntime` and `EventStreamRuntime`, each having their own sets of integration test prompts.
|
||||
|
||||
You can run:
|
||||
|
||||
```bash
|
||||
|
||||
@ -63,7 +63,6 @@ if [ "$TEST_RUNTIME" == "eventstream" ] && [ -z "$SANDBOX_CONTAINER_IMAGE" ]; th
|
||||
SANDBOX_CONTAINER_IMAGE="ubuntu:22.04"
|
||||
fi
|
||||
|
||||
PERSIST_SANDBOX=false
|
||||
MAX_ITERATIONS=15
|
||||
echo "SANDBOX_BOX_TYPE: $SANDBOX_BOX_TYPE"
|
||||
echo "TEST_RUNTIME: $TEST_RUNTIME"
|
||||
@ -114,7 +113,6 @@ run_test() {
|
||||
env SCRIPT_DIR="$SCRIPT_DIR" \
|
||||
PROJECT_ROOT="$PROJECT_ROOT" \
|
||||
SANDBOX_BOX_TYPE="$SANDBOX_BOX_TYPE" \
|
||||
PERSIST_SANDBOX=$PERSIST_SANDBOX \
|
||||
WORKSPACE_BASE=$WORKSPACE_BASE \
|
||||
WORKSPACE_MOUNT_PATH=$WORKSPACE_MOUNT_PATH \
|
||||
MAX_ITERATIONS=$MAX_ITERATIONS \
|
||||
@ -186,7 +184,6 @@ regenerate_without_llm() {
|
||||
env SCRIPT_DIR="$SCRIPT_DIR" \
|
||||
PROJECT_ROOT="$PROJECT_ROOT" \
|
||||
SANDBOX_BOX_TYPE="$SANDBOX_BOX_TYPE" \
|
||||
PERSIST_SANDBOX=$PERSIST_SANDBOX \
|
||||
WORKSPACE_BASE=$WORKSPACE_BASE \
|
||||
WORKSPACE_MOUNT_PATH=$WORKSPACE_MOUNT_PATH \
|
||||
MAX_ITERATIONS=$MAX_ITERATIONS \
|
||||
@ -217,7 +214,6 @@ regenerate_with_llm() {
|
||||
PROJECT_ROOT="$PROJECT_ROOT" \
|
||||
DEBUG=true \
|
||||
SANDBOX_BOX_TYPE="$SANDBOX_BOX_TYPE" \
|
||||
PERSIST_SANDBOX=$PERSIST_SANDBOX \
|
||||
WORKSPACE_BASE=$WORKSPACE_BASE \
|
||||
WORKSPACE_MOUNT_PATH=$WORKSPACE_MOUNT_PATH \
|
||||
DEFAULT_AGENT=$agent \
|
||||
|
||||
@ -2,7 +2,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from opendevin.runtime.server import files
|
||||
from opendevin.runtime.utils import files
|
||||
|
||||
SANDBOX_PATH_PREFIX = '/workspace'
|
||||
WORKSPACE_BASE = 'workspace'
|
||||
|
||||
@ -103,30 +103,6 @@ echo "Done"
|
||||
assert split_bash_commands(input_commands) == expected_output
|
||||
|
||||
|
||||
def test_jupyter_heredoc():
|
||||
"""This tests specifically test the behavior of the bash parser
|
||||
when the input is a heredoc for a Jupyter cell (used in ServerRuntime).
|
||||
|
||||
It will failed to parse bash commands AND fall back to the original input,
|
||||
which won't cause issues in actual execution.
|
||||
|
||||
[input]: cat > /tmp/opendevin_jupyter_temp.py <<'EOL'
|
||||
print('Hello, `World`!
|
||||
')
|
||||
EOL
|
||||
[warning]: here-document at line 0 delimited by end-of-file (wanted "'EOL'") (position 75)
|
||||
|
||||
TODO: remove this tests after the deprecation of ServerRuntime
|
||||
"""
|
||||
|
||||
code = "print('Hello, `World`!\n')"
|
||||
input_commands = f"""cat > /tmp/opendevin_jupyter_temp.py <<'EOL'
|
||||
{code}
|
||||
EOL"""
|
||||
expected_output = [f"cat > /tmp/opendevin_jupyter_temp.py <<'EOL'\n{code}\nEOL"]
|
||||
assert split_bash_commands(input_commands) == expected_output
|
||||
|
||||
|
||||
def test_backslash_continuation():
|
||||
input_commands = """
|
||||
echo "This is a long \
|
||||
|
||||
@ -378,7 +378,7 @@ def test_defaults_dict_after_updates(default_config):
|
||||
assert defaults_after_updates['sandbox']['timeout']['default'] == 120
|
||||
assert (
|
||||
defaults_after_updates['sandbox']['container_image']['default']
|
||||
== 'ghcr.io/opendevin/sandbox:main'
|
||||
== 'ubuntu:22.04'
|
||||
)
|
||||
assert defaults_after_updates == initial_defaults
|
||||
|
||||
|
||||
@ -32,7 +32,6 @@ from opendevin.events.observation import (
|
||||
from opendevin.runtime.client.runtime import EventStreamRuntime
|
||||
from opendevin.runtime.plugins import AgentSkillsRequirement, JupyterRequirement
|
||||
from opendevin.runtime.runtime import Runtime
|
||||
from opendevin.runtime.server.runtime import ServerRuntime
|
||||
from opendevin.storage import get_file_store
|
||||
|
||||
|
||||
@ -58,10 +57,8 @@ def get_box_classes():
|
||||
runtime = TEST_RUNTIME
|
||||
if runtime.lower() == 'eventstream':
|
||||
return [EventStreamRuntime]
|
||||
elif runtime.lower() == 'server':
|
||||
return [ServerRuntime]
|
||||
else:
|
||||
return [EventStreamRuntime, ServerRuntime]
|
||||
return [EventStreamRuntime]
|
||||
|
||||
|
||||
# This assures that all tests run together per runtime, not alternating between them,
|
||||
@ -146,19 +143,6 @@ async def _load_runtime(
|
||||
)
|
||||
await runtime.ainit()
|
||||
|
||||
elif box_class == ServerRuntime:
|
||||
runtime = ServerRuntime(
|
||||
config=config, event_stream=event_stream, sid=sid, plugins=plugins
|
||||
)
|
||||
await runtime.ainit()
|
||||
from opendevin.runtime.tools import (
|
||||
RuntimeTool, # deprecate this after ServerRuntime is deprecated
|
||||
)
|
||||
|
||||
runtime.init_runtime_tools(
|
||||
[RuntimeTool.BROWSER],
|
||||
runtime_tools_config={},
|
||||
)
|
||||
else:
|
||||
raise ValueError(f'Invalid box class: {box_class}')
|
||||
await asyncio.sleep(1)
|
||||
@ -335,11 +319,8 @@ async def test_simple_cmd_ipython_and_fileop(temp_dir, box_class, run_as_devin):
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
|
||||
assert obs.content == ''
|
||||
if box_class == ServerRuntime:
|
||||
assert obs.path == 'hello.sh'
|
||||
else:
|
||||
# event stream runtime will always use absolute path
|
||||
assert obs.path == '/workspace/hello.sh'
|
||||
# event stream runtime will always use absolute path
|
||||
assert obs.path == '/workspace/hello.sh'
|
||||
|
||||
# Test read file (file should exist)
|
||||
action_read = FileReadAction(path='hello.sh')
|
||||
@ -351,10 +332,7 @@ async def test_simple_cmd_ipython_and_fileop(temp_dir, box_class, run_as_devin):
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
|
||||
assert obs.content == 'echo "Hello, World!"\n'
|
||||
if box_class == ServerRuntime:
|
||||
assert obs.path == 'hello.sh'
|
||||
else:
|
||||
assert obs.path == '/workspace/hello.sh'
|
||||
assert obs.path == '/workspace/hello.sh'
|
||||
|
||||
# clean up
|
||||
action = CmdRunAction(command='rm -rf hello.sh')
|
||||
@ -571,14 +549,8 @@ async def test_no_ps2_in_output(temp_dir, box_class, run_as_devin):
|
||||
obs = await runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
|
||||
if box_class == ServerRuntime:
|
||||
# the extra PS2 '>' is NOT handled by the ServerRuntime
|
||||
assert 'hello\r\nworld' in obs.content
|
||||
assert '>' in obs.content
|
||||
assert obs.content.count('>') == 1
|
||||
else:
|
||||
assert 'hello\r\nworld' in obs.content
|
||||
assert '>' not in obs.content
|
||||
assert 'hello\r\nworld' in obs.content
|
||||
assert '>' not in obs.content
|
||||
|
||||
await runtime.close()
|
||||
await asyncio.sleep(1)
|
||||
@ -849,7 +821,7 @@ async def test_ipython_simple(temp_dir, box_class):
|
||||
|
||||
|
||||
async def _test_ipython_agentskills_fileop_pwd_impl(
|
||||
runtime: ServerRuntime | EventStreamRuntime, enable_auto_lint: bool
|
||||
runtime: EventStreamRuntime, enable_auto_lint: bool
|
||||
):
|
||||
# remove everything in /workspace
|
||||
action = CmdRunAction(command='rm -rf /workspace/*')
|
||||
@ -1042,35 +1014,6 @@ async def test_ipython_agentskills_fileop_pwd_with_userdir(temp_dir, box_class):
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get('TEST_IN_CI', 'false').lower() == 'true',
|
||||
# FIXME: There's some weird issue with the CI environment.
|
||||
reason='Skip this if in CI.',
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_ipython_agentskills_fileop_pwd_agnostic_sandbox(
|
||||
temp_dir, box_class, run_as_devin, enable_auto_lint, container_image
|
||||
):
|
||||
"""Make sure that cd in bash also updates the current working directory in iPython."""
|
||||
|
||||
# NOTE: we only test for ServerRuntime, since EventStreamRuntime
|
||||
# is image agnostic by design.
|
||||
if box_class != 'server':
|
||||
pytest.skip('Skip this if box_class is not server')
|
||||
|
||||
runtime = await _load_runtime(
|
||||
temp_dir,
|
||||
box_class,
|
||||
run_as_devin,
|
||||
enable_auto_lint=enable_auto_lint,
|
||||
container_image=container_image,
|
||||
)
|
||||
await _test_ipython_agentskills_fileop_pwd_impl(runtime, enable_auto_lint)
|
||||
|
||||
await runtime.close()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bash_python_version(temp_dir, box_class):
|
||||
"""Make sure Python is available in bash."""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user