Add port mappings support (#5577)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: tofarr <tofarr@gmail.com>
Co-authored-by: Robert Brennan <accounts@rbren.io>
Co-authored-by: Robert Brennan <contact@rbren.io>
This commit is contained in:
sp.wack
2025-01-09 19:02:56 +04:00
committed by GitHub
parent 3eae2e2aca
commit f6bed82ae2
15 changed files with 333 additions and 33 deletions

View File

@@ -0,0 +1,19 @@
interface PathFormProps {
ref: React.RefObject<HTMLFormElement | null>;
onBlur: () => void;
defaultValue: string;
}
export function PathForm({ ref, onBlur, defaultValue }: PathFormProps) {
return (
<form ref={ref} onSubmit={(e) => e.preventDefault()} className="flex-1">
<input
name="url"
type="text"
defaultValue={defaultValue}
className="w-full bg-transparent"
onBlur={onBlur}
/>
</form>
);
}

View File

@@ -0,0 +1,12 @@
import { useActiveHost } from "#/hooks/query/use-active-host";
export function ServedAppLabel() {
const { activeHost } = useActiveHost();
return (
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">App</div>
{activeHost && <div className="w-2 h-2 bg-green-500 rounded-full" />}
</div>
);
}

View File

@@ -50,10 +50,13 @@ async function prepareApp() {
}
}
const QUERY_KEYS_TO_IGNORE = ["authenticated", "hosts"];
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
if (!query.queryKey.includes("authenticated")) toast.error(error.message);
if (!QUERY_KEYS_TO_IGNORE.some((key) => query.queryKey.includes(key))) {
toast.error(error.message);
}
},
}),
defaultOptions: {

View File

@@ -0,0 +1,51 @@
import { useQueries, useQuery } from "@tanstack/react-query";
import axios from "axios";
import React from "react";
import { useSelector } from "react-redux";
import { openHands } from "#/api/open-hands-axios";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { RootState } from "#/store";
import { useConversation } from "#/context/conversation-context";
export const useActiveHost = () => {
const { curAgentState } = useSelector((state: RootState) => state.agent);
const [activeHost, setActiveHost] = React.useState<string | null>(null);
const { conversationId } = useConversation();
const { data } = useQuery({
queryKey: [conversationId, "hosts"],
queryFn: async () => {
const response = await openHands.get<{ hosts: string[] }>(
`/api/conversations/${conversationId}/web-hosts`,
);
return { hosts: Object.keys(response.data.hosts) };
},
enabled: !RUNTIME_INACTIVE_STATES.includes(curAgentState),
initialData: { hosts: [] },
});
const apps = useQueries({
queries: data.hosts.map((host) => ({
queryKey: [conversationId, "hosts", host],
queryFn: async () => {
try {
await axios.get(host);
return host;
} catch (e) {
return "";
}
},
refetchInterval: 3000,
})),
});
const appsData = apps.map((app) => app.data);
React.useEffect(() => {
const successfulApp = appsData.find((app) => app);
setActiveHost(successfulApp || "");
}, [appsData]);
return { activeHost };
};

View File

@@ -12,6 +12,7 @@ export default [
index("routes/_oh.app._index/route.tsx"),
route("browser", "routes/_oh.app.browser.tsx"),
route("jupyter", "routes/_oh.app.jupyter.tsx"),
route("served", "routes/app.tsx"),
]),
]),

View File

@@ -2,6 +2,7 @@ import { useDisclosure } from "@nextui-org/react";
import React from "react";
import { Outlet } from "react-router";
import { useDispatch, useSelector } from "react-redux";
import { FaServer } from "react-icons/fa";
import toast from "react-hot-toast";
import {
ConversationProvider,
@@ -31,6 +32,7 @@ import Security from "#/components/shared/modals/security/security";
import { useEndSession } from "#/hooks/use-end-session";
import { useUserConversation } from "#/hooks/query/use-user-conversation";
import { CountBadge } from "#/components/layout/count-badge";
import { ServedAppLabel } from "#/components/layout/served-app-label";
import { TerminalStatusLabel } from "#/components/features/terminal/terminal-status-label";
import { useSettings } from "#/hooks/query/use-settings";
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
@@ -126,6 +128,11 @@ function AppContent() {
labels={[
{ label: "Workspace", to: "", icon: <CodeIcon /> },
{ label: "Jupyter", to: "jupyter", icon: <ListIcon /> },
{
label: <ServedAppLabel />,
to: "served",
icon: <FaServer />,
},
{
label: (
<div className="flex items-center gap-1">

View File

@@ -0,0 +1,95 @@
import React from "react";
import { FaArrowRotateRight } from "react-icons/fa6";
import { FaExternalLinkAlt, FaHome } from "react-icons/fa";
import { useActiveHost } from "#/hooks/query/use-active-host";
import { PathForm } from "#/components/features/served-host/path-form";
function ServedApp() {
const { activeHost } = useActiveHost();
const [refreshKey, setRefreshKey] = React.useState(0);
const [currentActiveHost, setCurrentActiveHost] = React.useState<
string | null
>(null);
const [path, setPath] = React.useState<string>("hello");
const formRef = React.useRef<HTMLFormElement>(null);
const handleOnBlur = () => {
if (formRef.current) {
const formData = new FormData(formRef.current);
const urlInputValue = formData.get("url")?.toString();
if (urlInputValue) {
const url = new URL(urlInputValue);
setCurrentActiveHost(url.origin);
setPath(url.pathname);
}
}
};
const resetUrl = () => {
setCurrentActiveHost(activeHost);
setPath("");
if (formRef.current) {
formRef.current.reset();
}
};
React.useEffect(() => {
resetUrl();
}, [activeHost]);
const fullUrl = `${currentActiveHost}/${path}`;
if (!currentActiveHost) {
return (
<div className="flex items-center justify-center w-full h-full p-10">
<span className="text-neutral-400 font-bold">
If you tell OpenHands to start a web server, the app will appear here.
</span>
</div>
);
}
return (
<div className="h-full w-full">
<div className="w-full p-2 flex items-center gap-4 border-b border-neutral-600">
<button
type="button"
onClick={() => window.open(fullUrl, "_blank")}
className="text-sm"
>
<FaExternalLinkAlt className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => setRefreshKey((prev) => prev + 1)}
className="text-sm"
>
<FaArrowRotateRight className="w-4 h-4" />
</button>
<button type="button" onClick={() => resetUrl()} className="text-sm">
<FaHome className="w-4 h-4" />
</button>
<div className="w-full flex">
<PathForm
ref={formRef}
onBlur={handleOnBlur}
defaultValue={fullUrl}
/>
</div>
</div>
<iframe
key={refreshKey}
title="Served App"
src={fullUrl}
className="w-full h-full"
/>
</div>
);
}
export default ServedApp;

View File

@@ -3,9 +3,21 @@ You are OpenHands agent, a helpful AI assistant that can interact with a compute
* If user provides a path, you should NOT assume it's relative to the current working directory. Instead, you should explore the file system to find the file before working on it.
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
* The assistant MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior.
{{ runtime_info }}
</IMPORTANT>
{% if repo_instructions %}
{% if repo_instructions -%}
<REPOSITORY_INSTRUCTIONS>
{{ repo_instructions }}
</REPOSITORY_INSTRUCTIONS>
{% endif %}
{% if runtime_info and runtime_info.available_hosts -%}
<RUNTIME_INFORMATION>
The user has access to the following hosts for accessing a web application,
each of which has a corresponding port:
{% for host, port in runtime_info.available_hosts.items() -%}
* {{ host }} (port {{ port }})
{% endfor %}
When starting a web server, use the corresponding ports. You should also
set any options to allow iframes and CORS requests.
</RUNTIME_INFORMATION>
{% endif %}

View File

@@ -57,7 +57,6 @@ from openhands.runtime.plugins import ALL_PLUGINS, JupyterPlugin, Plugin, VSCode
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.runtime.utils.system_stats import get_system_stats
from openhands.utils.async_utils import call_sync_from_async, wait_all
@@ -435,8 +434,6 @@ if __name__ == '__main__':
)
# 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:

View File

@@ -400,3 +400,7 @@ class Runtime(FileEditRuntimeMixin):
@property
def vscode_url(self) -> str | None:
raise NotImplementedError('This method is not implemented in the base class.')
@property
def web_hosts(self) -> dict[str, int]:
return {}

View File

@@ -28,6 +28,11 @@ from openhands.utils.tenacity_stop import stop_if_should_exit
CONTAINER_NAME_PREFIX = 'openhands-runtime-'
EXECUTION_SERVER_PORT_RANGE = (30000, 39999)
VSCODE_PORT_RANGE = (40000, 49999)
APP_PORT_RANGE_1 = (50000, 54999)
APP_PORT_RANGE_2 = (55000, 59999)
def remove_all_runtime_containers():
remove_all_containers(CONTAINER_NAME_PREFIX)
@@ -65,13 +70,17 @@ class DockerRuntime(ActionExecutionClient):
atexit.register(remove_all_runtime_containers)
self.config = config
self._host_port = 30000 # initial dummy value
self._container_port = 30001 # initial dummy value
self._runtime_initialized: bool = False
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
self.status_callback = status_callback
self._host_port = -1
self._container_port = -1
self._vscode_port = -1
self._app_ports: list[int] = []
self.docker_client: docker.DockerClient = self._init_docker_client()
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
self.base_container_image = self.config.sandbox.base_container_image
self.runtime_container_image = self.config.sandbox.runtime_container_image
self.container_name = CONTAINER_NAME_PREFIX + sid
@@ -182,22 +191,35 @@ class DockerRuntime(ActionExecutionClient):
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
) # in future this might differ from host port
self._host_port = self._find_available_port(EXECUTION_SERVER_PORT_RANGE)
self._container_port = self._host_port
self._vscode_port = self._find_available_port(VSCODE_PORT_RANGE)
self._app_ports = [
self._find_available_port(APP_PORT_RANGE_1),
self._find_available_port(APP_PORT_RANGE_2),
]
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
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
else {f'{self._container_port}/tcp': [{'HostPort': str(self._host_port)}]}
)
use_host_network = self.config.sandbox.use_host_network
if use_host_network:
# Initialize port mappings
port_mapping: dict[str, list[dict[str, str]]] | None = None
if not use_host_network:
port_mapping = {
f'{self._container_port}/tcp': [{'HostPort': str(self._host_port)}],
}
if self.vscode_enabled:
port_mapping[f'{self._vscode_port}/tcp'] = [
{'HostPort': str(self._vscode_port)}
]
for port in self._app_ports:
port_mapping[f'{port}/tcp'] = [{'HostPort': str(port)}]
else:
self.log(
'warn',
'Using host network mode. If you are using MacOS, please make sure you have the latest version of Docker Desktop and enabled host network feature: https://docs.docker.com/network/drivers/host/#docker-desktop',
@@ -207,17 +229,11 @@ class DockerRuntime(ActionExecutionClient):
environment = {
'port': str(self._container_port),
'PYTHONUNBUFFERED': 1,
'VSCODE_PORT': str(self._vscode_port),
}
if self.config.debug or DEBUG:
environment['DEBUG'] = 'true'
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.log('debug', f'Workspace Base: {self.config.workspace_base}')
if (
self.config.workspace_mount_path is not None
@@ -291,6 +307,8 @@ class DockerRuntime(ActionExecutionClient):
'error',
f'Error: Instance {self.container_name} FAILED to start container!\n',
)
self.log('error', str(e))
raise e
except Exception as e:
self.log(
'error',
@@ -301,11 +319,20 @@ class DockerRuntime(ActionExecutionClient):
raise e
def _attach_to_container(self):
self._container_port = 0
self.container = self.docker_client.containers.get(self.container_name)
for port in self.container.attrs['NetworkSettings']['Ports']: # type: ignore
self._container_port = int(port.split('/')[0])
break
port = int(port.split('/')[0])
if (
port >= EXECUTION_SERVER_PORT_RANGE[0]
and port <= EXECUTION_SERVER_PORT_RANGE[1]
):
self._container_port = port
if port >= VSCODE_PORT_RANGE[0] and port <= VSCODE_PORT_RANGE[1]:
self._vscode_port = port
elif port >= APP_PORT_RANGE_1[0] and port <= APP_PORT_RANGE_1[1]:
self._app_ports.append(port)
elif port >= APP_PORT_RANGE_2[0] and port <= APP_PORT_RANGE_2[1]:
self._app_ports.append(port)
self._host_port = self._container_port
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
self.log(
@@ -363,10 +390,10 @@ class DockerRuntime(ActionExecutionClient):
return True
return False
def _find_available_port(self, max_attempts=5):
port = 39999
def _find_available_port(self, port_range, max_attempts=5):
port = port_range[1]
for _ in range(max_attempts):
port = find_available_tcp_port(30000, 39999)
port = find_available_tcp_port(port_range[0], port_range[1])
if not self._is_port_in_use_docker(port):
return port
# If no port is found after max_attempts, return the last tried port
@@ -377,5 +404,15 @@ class DockerRuntime(ActionExecutionClient):
token = super().get_vscode_token()
if not token:
return None
vscode_url = f'http://localhost:{self._host_port + 1}/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
vscode_url = f'http://localhost:{self._vscode_port}/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
return vscode_url
@property
def web_hosts(self):
hosts: dict[str, int] = {}
for port in self._app_ports:
hosts[f'http://localhost:{port}'] = port
return hosts

View File

@@ -70,6 +70,7 @@ class RemoteRuntime(ActionExecutionClient):
)
self.runtime_id: str | None = None
self.runtime_url: str | None = None
self.available_hosts: dict[str, int] = {}
self._runtime_initialized: bool = False
def _get_action_execution_server_host(self):
@@ -257,6 +258,8 @@ class RemoteRuntime(ActionExecutionClient):
start_response = response.json()
self.runtime_id = start_response['runtime_id']
self.runtime_url = start_response['url']
self.available_hosts = start_response.get('work_hosts', {})
if 'session_api_key' in start_response:
self.session.headers.update(
{'X-Session-API-Key': start_response['session_api_key']}
@@ -278,6 +281,10 @@ class RemoteRuntime(ActionExecutionClient):
)
return vscode_url
@property
def web_hosts(self) -> dict[str, int]:
return self.available_hosts
def _wait_until_alive(self):
retry_decorator = tenacity.retry(
stop=tenacity.stop_after_delay(

View File

@@ -52,6 +52,45 @@ async def get_vscode_url(request: Request):
)
@app.get('/web-hosts')
async def get_hosts(request: Request):
"""Get the hosts used by the runtime.
This endpoint allows getting the hosts used by the runtime.
Args:
request (Request): The incoming FastAPI request object.
Returns:
JSONResponse: A JSON response indicating the success of the operation.
"""
try:
if not hasattr(request.state, 'conversation'):
return JSONResponse(
status_code=500,
content={'error': 'No conversation found in request state'},
)
if not hasattr(request.state.conversation, 'runtime'):
return JSONResponse(
status_code=500, content={'error': 'No runtime found in conversation'}
)
runtime: Runtime = request.state.conversation.runtime
logger.debug(f'Runtime type: {type(runtime)}')
logger.debug(f'Runtime hosts: {runtime.web_hosts}')
return JSONResponse(status_code=200, content={'hosts': runtime.web_hosts})
except Exception as e:
logger.error(f'Error getting runtime hosts: {e}', exc_info=True)
return JSONResponse(
status_code=500,
content={
'hosts': None,
'error': f'Error getting runtime hosts: {e}',
},
)
@app.get('/events/search')
async def search_events(
request: Request,

View File

@@ -216,7 +216,9 @@ class AgentSession:
await call_sync_from_async(
self.runtime.clone_repo, github_token, selected_repository
)
if agent.prompt_manager:
agent.prompt_manager.set_runtime_info(self.runtime)
microagents: list[BaseMicroAgent] = await call_sync_from_async(
self.runtime.get_microagents_from_selected_repo, selected_repository
)

View File

@@ -1,4 +1,5 @@
import os
from dataclasses import dataclass
from itertools import islice
from jinja2 import Template
@@ -11,6 +12,12 @@ from openhands.microagent import (
RepoMicroAgent,
load_microagents_from_dir,
)
from openhands.runtime.base import Runtime
@dataclass
class RuntimeInfo:
available_hosts: dict[str, int]
class PromptManager:
@@ -38,6 +45,7 @@ class PromptManager:
self.system_template: Template = self._load_template('system_prompt')
self.user_template: Template = self._load_template('user_prompt')
self.runtime_info = RuntimeInfo(available_hosts={})
self.knowledge_microagents: dict[str, KnowledgeMicroAgent] = {}
self.repo_microagents: dict[str, RepoMicroAgent] = {}
@@ -72,6 +80,9 @@ class PromptManager:
elif isinstance(microagent, RepoMicroAgent):
self.repo_microagents[microagent.name] = microagent
def set_runtime_info(self, runtime: Runtime):
self.runtime_info.available_hosts = runtime.web_hosts
def _load_template(self, template_name: str) -> Template:
if self.prompt_dir is None:
raise ValueError('Prompt directory is not set')
@@ -91,7 +102,9 @@ class PromptManager:
if repo_instructions:
repo_instructions += '\n\n'
repo_instructions += microagent.content
return self.system_template.render(repo_instructions=repo_instructions).strip()
return self.system_template.render(
runtime_info=self.runtime_info, repo_instructions=repo_instructions
).strip()
def get_example_user_message(self) -> str:
"""This is the initial user message provided to the agent
@@ -103,6 +116,7 @@ class PromptManager:
These additional context will convert the current generic agent
into a more specialized agent that is tailored to the user's task.
"""
return self.user_template.render().strip()
def enhance_message(self, message: Message) -> None: