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

Add port mappings support #5577

Merged
merged 61 commits into from
Jan 9, 2025
Merged
Show file tree
Hide file tree
Changes from 49 commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
e41ca72
Add port mappings support to sandbox config
openhands-agent Dec 13, 2024
c7d3225
Add UI for served app
amanape Dec 13, 2024
4bc7721
Merge branch 'main' into add-port-mappings
amanape Dec 16, 2024
291c496
Create new property
amanape Dec 16, 2024
9cee2c3
Merge branch 'main' into add-port-mappings
amanape Dec 17, 2024
42876f3
Multi port support and extend base and remote runtimes
amanape Dec 17, 2024
a84f7e6
Merge and resolve
amanape Dec 17, 2024
395527d
Adjustment and changes
amanape Dec 17, 2024
e68a658
Small edit
amanape Dec 17, 2024
68215db
Add runtime-specific port mapping instructions to agent prompt
openhands-agent Dec 18, 2024
c03c1ad
Fix template modification to use file reading instead of non-existent…
openhands-agent Dec 18, 2024
468b61c
Move prompt extension logic to PromptManager class
openhands-agent Dec 18, 2024
7b4628b
Some fixes and improvements
amanape Dec 18, 2024
80d92ae
Merge and resolve
amanape Dec 19, 2024
d3018c2
Improve jinja template handling
amanape Dec 19, 2024
4b9a65a
Fix comments
amanape Dec 19, 2024
0a23fd9
Mostly renaming
amanape Dec 19, 2024
65a419f
Merge and resolve
amanape Dec 19, 2024
b1af1db
Address prompt handling
amanape Dec 19, 2024
75ca147
Merge branch 'main' into add-port-mappings
amanape Dec 20, 2024
0e700d1
Merge and resolve
amanape Dec 20, 2024
0cef034
Address comments
amanape Dec 20, 2024
eac950a
Update instructions
amanape Dec 20, 2024
301231b
Merge, resolve, and fix error
amanape Dec 23, 2024
c55fa09
Fix error
amanape Dec 23, 2024
c5b7382
merge and resolve
amanape Dec 27, 2024
a0d2efd
Rename
amanape Dec 27, 2024
30f0b49
Merge branch 'main' into add-port-mappings
tofarr Dec 27, 2024
6a3306e
Lint fix
tofarr Dec 27, 2024
7aa527d
merge
amanape Dec 31, 2024
020df3c
Merge branch 'main' into add-port-mappings
rbren Jan 1, 2025
ac973fa
make prompting clearer
rbren Jan 1, 2025
ff07644
handle errors
rbren Jan 1, 2025
0130246
fix llm err handling
rbren Jan 1, 2025
6ac55ca
Merge branch 'rb/fix-errs' into add-port-mappings
rbren Jan 1, 2025
d9814cf
json for dict configs
rbren Jan 1, 2025
a2ae2d9
fix prompt
rbren Jan 1, 2025
37c671a
dynamic port mapping
rbren Jan 1, 2025
8f67d2d
fix ports
rbren Jan 1, 2025
108bce0
fix port initialization
rbren Jan 1, 2025
6ebdf16
remove offline label
rbren Jan 1, 2025
6d078f8
fix up iframe
rbren Jan 1, 2025
3c6d5f8
change web_hosts api
rbren Jan 1, 2025
7401215
fix frontend for new api
rbren Jan 1, 2025
5122f83
delint
rbren Jan 1, 2025
aaa0b22
delint
rbren Jan 1, 2025
33cbc1e
change to work_hosts
rbren Jan 1, 2025
182071d
remove log
rbren Jan 1, 2025
6bf627d
fix vscode
rbren Jan 1, 2025
5ba5766
Merge branch 'main' into add-port-mappings
amanape Jan 2, 2025
282f5b4
Add refresh button and small active indicator
amanape Jan 2, 2025
c98ceca
Allow editing path and create redirect button
amanape Jan 2, 2025
c951c36
Merge branch 'main' into add-port-mappings
amanape Jan 2, 2025
ea7691b
Allow to edit full URL
amanape Jan 2, 2025
cfcac55
Resolve merge conflicts with main
openhands-agent Jan 3, 2025
a99fb0e
Apply pre-commit fixes
openhands-agent Jan 3, 2025
c2ab895
Merge branch 'main' into add-port-mappings
rbren Jan 6, 2025
5d51a35
change app port ranges
rbren Jan 8, 2025
99489d7
Merge branch 'main' into add-port-mappings
rbren Jan 8, 2025
1854883
Merge branch 'main' into add-port-mappings
amanape Jan 9, 2025
ae92c8f
Update openhands/runtime/impl/docker/docker_runtime.py
rbren Jan 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions frontend/src/components/layout/served-app-label.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { FaExternalLinkAlt } from "react-icons/fa";
import { useActiveHost } from "#/hooks/query/use-active-host";

export function ServedAppLabel() {
const { activeHost } = useActiveHost();

function openAppInNewTab() {
if (!activeHost) return;
window.open(activeHost, "_blank");
}

return (
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">App</div>
{activeHost && (
<span onClick={openAppInNewTab} className="flex items-center gap-2">
<div className="flex items-center gap-1">
<FaExternalLinkAlt fill="#a3a3a3" />
</div>
</span>
)}
</div>
);
}
5 changes: 4 additions & 1 deletion frontend/src/entry.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
51 changes: 51 additions & 0 deletions frontend/src/hooks/query/use-active-host.ts
Original file line number Diff line number Diff line change
@@ -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 };
};
1 change: 1 addition & 0 deletions frontend/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]),
]),

Expand Down
7 changes: 7 additions & 0 deletions frontend/src/routes/_oh.app/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -32,6 +33,7 @@ import Security from "#/components/shared/modals/security/security";
import { useEndSession } from "#/hooks/use-end-session";
import { useUserConversation } from "#/hooks/query/get-conversation-permissions";
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_CONVO_UI_IS_ENABLED } from "#/utils/constants";
Expand Down Expand Up @@ -139,6 +141,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">
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/routes/app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useActiveHost } from "#/hooks/query/use-active-host";

function ServedApp() {
const { activeHost } = useActiveHost();

if (!activeHost) {
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 (
<iframe title="Served App" src={activeHost} className="w-full h-full" />
);
}

export default ServedApp;
14 changes: 13 additions & 1 deletion openhands/agenthub/codeact_agent/prompts/system_prompt.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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 "[email protected]" 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 %}
2 changes: 1 addition & 1 deletion openhands/controller/agent_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ async def _step_with_exception_handling(self):
reported = RuntimeError(
'There was an unexpected error while running the agent.'
)
if isinstance(e, litellm.LLMError):
if isinstance(e, litellm.AuthenticationError):
reported = e
await self._react_to_exception(reported)

Expand Down
3 changes: 0 additions & 3 deletions openhands/runtime/action_execution_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@
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

Expand Down Expand Up @@ -421,8 +420,6 @@ 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
4 changes: 4 additions & 0 deletions openhands/runtime/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,3 +352,7 @@ def vscode_enabled(self) -> bool:
@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 {}
85 changes: 58 additions & 27 deletions openhands/runtime/impl/docker/docker_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@

CONTAINER_NAME_PREFIX = 'openhands-runtime-'

APP_PORT_RANGE = (50000, 59999)
VSCODE_PORT_RANGE = (40000, 49999)
EXECUTION_SERVER_PORT_RANGE = (30000, 39999)


def remove_all_runtime_containers():
remove_all_containers(CONTAINER_NAME_PREFIX)
Expand Down Expand Up @@ -66,13 +70,17 @@ def __init__(
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
Expand Down Expand Up @@ -180,22 +188,32 @@ 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
) # 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) for _ in range(2)]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems this would return two identical ports?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

huh...it doesn't, but now that you say it I don't know why....

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's grabbing random ports, so there's a 1-in-10k chance of a collision. Will fix

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

# 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)}
]

if use_host_network:
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',
Expand All @@ -205,17 +223,11 @@ def _init_container(self):
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
Expand Down Expand Up @@ -283,6 +295,8 @@ def _init_container(self):
'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',
Expand All @@ -293,11 +307,18 @@ def _init_container(self):
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
if port >= APP_PORT_RANGE[0] and port <= APP_PORT_RANGE[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(
Expand Down Expand Up @@ -358,10 +379,10 @@ def _is_port_in_use_docker(self, port):
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
Expand All @@ -372,5 +393,15 @@ def vscode_url(self) -> str | None:
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
7 changes: 7 additions & 0 deletions openhands/runtime/impl/remote/remote_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ def __init__(
)
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):
Expand Down Expand Up @@ -256,6 +257,8 @@ def _parse_runtime_response(self, response: requests.Response):
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']}
Expand All @@ -277,6 +280,10 @@ def vscode_url(self) -> str | None:
)
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(
Expand Down
Loading
Loading