Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add VSCode to OpenHands runtime and UI #4745

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
9 changes: 9 additions & 0 deletions frontend/src/api/open-hands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
GitHubAccessTokenResponse,
ErrorResponse,
GetConfigResponse,
GetVSCodeUrlResponse,
} from "./open-hands.types";

class OpenHands {
Expand Down Expand Up @@ -149,6 +150,14 @@ class OpenHands {
true,
);
}

/**
* Get the VSCode URL
* @returns VSCode URL
*/
static async getVSCodeUrl(): Promise<GetVSCodeUrlResponse> {
return request(`/api/vscode-url`);
}
}

export default OpenHands;
5 changes: 5 additions & 0 deletions frontend/src/api/open-hands.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,8 @@ export interface GetConfigResponse {
APP_MODE: "saas" | "oss";
GITHUB_CLIENT_ID: string | null;
}

export interface GetVSCodeUrlResponse {
vscode_url: string | null;
error?: string;
}
57 changes: 57 additions & 0 deletions frontend/src/assets/vscode-alt.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions frontend/src/routes/_oh.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
import NewProjectIcon from "#/assets/new-project.svg?react";
import DocsIcon from "#/assets/docs.svg?react";
import VSCodeIcon from "#/assets/vscode-alt.svg?react";
import { userIsAuthenticated } from "#/utils/user-is-authenticated";
import { generateGitHubAuthUrl } from "#/utils/generate-github-auth-url";
import { WaitlistModal } from "#/components/waitlist-modal";
Expand Down Expand Up @@ -228,6 +229,21 @@
});
};

const handleVSCodeClick = async (e: React.MouseEvent) => {
e.preventDefault();
try {
const response = await OpenHands.getVSCodeUrl();
console.log(response);

Check warning on line 236 in frontend/src/routes/_oh.tsx

View workflow job for this annotation

GitHub Actions / Lint frontend

Unexpected console statement
if (response.vscode_url) {
window.open(response.vscode_url, "_blank");
} else {
console.error("Failed to fetch VSCode URL:", response.error);

Check warning on line 240 in frontend/src/routes/_oh.tsx

View workflow job for this annotation

GitHub Actions / Lint frontend

Unexpected console statement
}
} catch (error) {
console.error("Failed to fetch VSCode URL:", error);

Check warning on line 243 in frontend/src/routes/_oh.tsx

View workflow job for this annotation

GitHub Actions / Lint frontend

Unexpected console statement
}
};

return (
<div
data-testid="root-layout"
Expand Down Expand Up @@ -276,6 +292,14 @@
>
<DocsIcon width={28} height={28} />
</a>
<button
type="button"
onClick={handleVSCodeClick}
className="w-8 h-8 rounded-full hover:opacity-80 flex items-center justify-center"
aria-label="VSCode"
>
<VSCodeIcon width={25} height={25} />
</button>
{!!token && (
<button
type="button"
Expand Down
8 changes: 7 additions & 1 deletion openhands/runtime/action_execution_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
from openhands.runtime.utils.bash import BashSession
from openhands.runtime.utils.files import insert_lines, read_lines
from openhands.runtime.utils.runtime_init import init_user_and_working_directory
from openhands.runtime.utils.system import check_port_available
from openhands.utils.async_utils import wait_all


Expand Down Expand Up @@ -114,7 +115,10 @@ def initial_pwd(self):
return self._initial_pwd

async def ainit(self):
await wait_all(self._init_plugin(plugin) for plugin in self.plugins_to_load)
await wait_all(
(self._init_plugin(plugin) for plugin in self.plugins_to_load),
timeout=30,
)

# This is a temporary workaround
# TODO: refactor AgentSkills to be part of JupyterPlugin
Expand Down Expand Up @@ -316,6 +320,8 @@ def close(self):
)
# example: python client.py 8000 --working-dir /workspace --plugins JupyterRequirement
args = parser.parse_args()
os.environ['VSCODE_PORT'] = str(int(args.port) + 1)
assert check_port_available(int(os.environ['VSCODE_PORT']))

plugins_to_load: list[Plugin] = []
if args.plugins:
Expand Down
19 changes: 18 additions & 1 deletion openhands/runtime/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@
UserRejectObservation,
)
from openhands.events.serialization.action import ACTION_TYPE_TO_CLASS
from openhands.runtime.plugins import JupyterRequirement, PluginRequirement
from openhands.runtime.plugins import (
JupyterRequirement,
PluginRequirement,
VSCodeRequirement,
)
from openhands.runtime.utils.edit import FileEditRuntimeMixin
from openhands.utils.async_utils import call_sync_from_async

Expand Down Expand Up @@ -73,6 +77,9 @@ class Runtime(FileEditRuntimeMixin):
initial_env_vars: dict[str, str]
attach_to_existing: bool
status_callback: Callable | None
DEFAULT_PLUGINS: list[PluginRequirement] = [
VSCodeRequirement(),
]

def __init__(
self,
Expand All @@ -90,6 +97,8 @@ def __init__(
EventStreamSubscriber.RUNTIME, self.on_event, self.sid
)
self.plugins = plugins if plugins is not None and len(plugins) > 0 else []
for plugin in self.DEFAULT_PLUGINS:
self.plugins.append(plugin)
self.status_callback = status_callback
self.attach_to_existing = attach_to_existing

Expand Down Expand Up @@ -277,3 +286,11 @@ def list_files(self, path: str | None = None) -> list[str]:
def copy_from(self, path: str) -> bytes:
"""Zip all files in the sandbox and return as a stream of bytes."""
raise NotImplementedError('This method is not implemented in the base class.')

# ====================================================================
# VSCode URL
# ====================================================================

@property
def vscode_url(self) -> str | None:
raise NotImplementedError('This method is not implemented in the base class.')
32 changes: 28 additions & 4 deletions openhands/runtime/impl/eventstream/eventstream_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
from openhands.events.serialization.action import ACTION_TYPE_TO_CLASS
from openhands.runtime.base import Runtime
from openhands.runtime.builder import DockerRuntimeBuilder
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.plugins import PluginRequirement, VSCodeRequirement
from openhands.runtime.utils import find_available_tcp_port
from openhands.runtime.utils.request import send_request
from openhands.runtime.utils.runtime_build import build_runtime_image
Expand Down Expand Up @@ -150,6 +150,7 @@ def __init__(
self.config = config
self._host_port = 30000 # initial dummy value
self._container_port = 30001 # initial dummy value
self._vscode_url: str | None = None # initial dummy value
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
self.session = requests.Session()
self.status_callback = status_callback
Expand Down Expand Up @@ -185,6 +186,9 @@ def __init__(
status_callback,
attach_to_existing,
)
self._vscode_enabled = any(
isinstance(plugin, VSCodeRequirement) for plugin in self.plugins
)

async def connect(self):
self.send_status_message('STATUS$STARTING_RUNTIME')
Expand All @@ -207,7 +211,10 @@ async def connect(self):
'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}')
self.log(
'info',
f'Container started: {self.container_name}. VSCode URL: {self.vscode_url}',
)

else:
await call_sync_from_async(self._attach_to_container)
Expand All @@ -224,7 +231,7 @@ async def connect(self):

self.log(
'debug',
f'Container initialized with plugins: {[plugin.name for plugin in self.plugins]}',
f'Container initialized with plugins: {[plugin.name for plugin in self.plugins]}. VSCode URL: {self.vscode_url}',
)
self.send_status_message(' ')

Expand All @@ -239,6 +246,10 @@ def _init_docker_client() -> docker.DockerClient:
)
raise ex

@property
def vscode_url(self) -> str | None:
return self._vscode_url

def _init_container(self):
self.log('debug', 'Preparing to start container...')
self.send_status_message('STATUS$PREPARING_CONTAINER')
Expand All @@ -247,7 +258,6 @@ def _init_container(self):
plugin_arg = (
f'--plugins {" ".join([plugin.name for plugin in self.plugins])} '
)

self._host_port = self._find_available_port()
self._container_port = (
self._host_port
Expand All @@ -256,6 +266,7 @@ def _init_container(self):

use_host_network = self.config.sandbox.use_host_network
network_mode: str | None = 'host' if use_host_network else None

port_mapping: dict[str, list[dict[str, str]]] | None = (
None
if use_host_network
Expand All @@ -268,6 +279,14 @@ def _init_container(self):
'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',
)

if self._vscode_enabled:
# vscode is on port +1 from container port
if isinstance(port_mapping, dict):
port_mapping[f'{self._container_port + 1}/tcp'] = [
{'HostPort': str(self._host_port + 1)}
]
self._vscode_url = f'http://localhost:{self._host_port + 1}/?folder={self.config.workspace_mount_path_in_sandbox}'

# Combine environment variables
environment = {
'port': str(self._container_port),
Expand Down Expand Up @@ -364,6 +383,11 @@ def _attach_to_container(self):
break
self._host_port = self._container_port
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
self._vscode_url = (
f'http://localhost:{self._host_port + 1}/?folder={self.config.workspace_mount_path_in_sandbox}'
if self._vscode_enabled
else None
)
self.log(
'debug',
f'attached to container: {self.container_name} {self._container_port} {self.api_url}',
Expand Down
4 changes: 4 additions & 0 deletions openhands/runtime/plugins/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
)
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',
Expand All @@ -13,9 +14,12 @@
'AgentSkillsPlugin',
'JupyterRequirement',
'JupyterPlugin',
'VSCodeRequirement',
'VSCodePlugin',
]

ALL_PLUGINS = {
'jupyter': JupyterPlugin,
'agent_skills': AgentSkillsPlugin,
'vscode': VSCodePlugin,
}
48 changes: 48 additions & 0 deletions openhands/runtime/plugins/vscode/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import os
import subprocess
import time
from dataclasses import dataclass

from openhands.core.logger import openhands_logger as logger
from openhands.runtime.plugins.requirement import Plugin, PluginRequirement
from openhands.runtime.utils.shutdown_listener import should_continue
from openhands.runtime.utils.system import check_port_available


@dataclass
class VSCodeRequirement(PluginRequirement):
name: str = 'vscode'


class VSCodePlugin(Plugin):
name: str = 'vscode'

async def initialize(self, username: str):
self.vscode_port = int(os.environ['VSCODE_PORT'])
assert check_port_available(self.vscode_port)
self._vscode_url = f'http://localhost:{self.vscode_port}'
self.gateway_process = subprocess.Popen(
(
f"su - {username} -s /bin/bash << 'EOF'\n"
f'sudo chown -R {username}:{username} /openhands/.openvscode-server\n'
'cd /workspace\n'
f'exec /openhands/.openvscode-server/bin/openvscode-server --host 0.0.0.0 --without-connection-token --port {self.vscode_port}\n'
'EOF'
),
stderr=subprocess.STDOUT,
shell=True,
)
# read stdout until the kernel gateway is ready
output = ''
while should_continue() and self.gateway_process.stdout is not None:
line = self.gateway_process.stdout.readline().decode('utf-8')
print(line)
output += line
if 'at' in line:
break
time.sleep(1)
logger.debug('Waiting for VSCode server to start...')

logger.debug(
f'VSCode server started at port {self.vscode_port}. Output: {output}'
)
Loading
Loading