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 16 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
27 changes: 27 additions & 0 deletions frontend/src/components/layout/served-app-label.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { FaExternalLinkAlt } from "react-icons/fa";
import { useActivePort } from "#/hooks/query/use-active-port";

export function ServedAppLabel() {
const { activePort } = useActivePort();

return (
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">App</div>
{!activePort && <span className="text-red-500">Offline</span>}
{activePort && (
<a
href="http://localhost:4141"
target="_blank"
rel="noreferrer"
className="flex items-center gap-2"
>
<span className="text-green-500">Online</span>
<div className="flex items-center gap-1">
<FaExternalLinkAlt fill="#a3a3a3" />
<code className="text-xs">{activePort.split(":").pop()}</code>
</div>
</a>
)}
</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", "ports"];
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
46 changes: 46 additions & 0 deletions frontend/src/hooks/query/use-active-port.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useQueries, useQuery } from "@tanstack/react-query";
import axios from "axios";
import React from "react";
import { openHands } from "#/api/open-hands-axios";
import {
useWsClient,
WsClientProviderStatus,
} from "#/context/ws-client-provider";

export const useActivePort = () => {
const { status } = useWsClient();
const [activePort, setActivePort] = React.useState<string | null>(null);

const { data } = useQuery({
queryKey: ["ports"],
queryFn: async () => {
const response = await openHands.get<{ ports: string[] }>("/api/ports");
return response.data;
},
enabled: status === WsClientProviderStatus.ACTIVE,
initialData: { ports: [] },
});

const apps = useQueries({
queries: data.ports.map((port) => ({
queryKey: ["ports", port],
queryFn: async () => axios.get(port),
refetchInterval: 3000,
})),
});

const success = apps.map((app) => app.isSuccess);

React.useEffect(() => {
const successfulApp = apps.find((app) => app.isSuccess);
if (successfulApp) {
const index = apps.indexOf(successfulApp);
const port = data.ports[index];
setActivePort(port);
} else {
setActivePort(null);
}
}, [success, data]);

return { activePort };
};
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 { Controls } from "#/components/features/controls/controls";
import { RootState } from "#/store";
import { clearMessages } from "#/state/chat-slice";
Expand All @@ -22,6 +23,7 @@ import { useConversationConfig } from "#/hooks/query/use-conversation-config";
import { Container } from "#/components/layout/container";
import Security from "#/components/shared/modals/security/security";
import { CountBadge } from "#/components/layout/count-badge";
import { ServedAppLabel } from "#/components/layout/served-app-label";
import { TerminalStatusLabel } from "#/components/features/terminal/terminal-status-label";

function App() {
Expand Down Expand Up @@ -84,6 +86,11 @@ function App() {
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 { useActivePort } from "#/hooks/query/use-active-port";

function ServedApp() {
const { activePort } = useActivePort();

if (!activePort) {
return (
<div className="flex items-center justify-center w-full h-full">
<span className="text-4xl text-neutral-400 font-bold">
Nothing to see here.
</span>
</div>
);
}

return (
<iframe title="Served App" src={activePort} className="w-full h-full" />
);
}

export default ServedApp;
1 change: 1 addition & 0 deletions openhands/agenthub/codeact_agent/prompts/system_prompt.j2
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ 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.
{{ additional_instruction }}
</IMPORTANT>
5 changes: 5 additions & 0 deletions openhands/core/config/sandbox_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ class SandboxConfig:
browsergym_eval_env: The BrowserGym environment to use for evaluation.
Default is None for general purpose browsing. Check evaluation/miniwob and evaluation/webarena for examples.
platform: The platform on which the image should be built. Default is None.
port_mappings: Custom port mappings from container ports to host ports. Default is empty dict.
This is a dictionary where keys are container ports and values are host ports.
For example, {8080: 8080} maps container port 8080 to host port 8080.
Note: Port 4141 is always mapped to host port 4141 by default.
"""

remote_runtime_api_url: str = 'http://localhost:8000'
Expand All @@ -56,6 +60,7 @@ class SandboxConfig:
browsergym_eval_env: str | None = None
platform: str | None = None
close_delay: int = 15
port_mappings: dict[int, int] = field(default_factory=dict)

def defaults_to_dict(self) -> dict:
"""Serialize fields to a dict for the frontend, including type hints, defaults, and whether it's optional."""
Expand Down
4 changes: 4 additions & 0 deletions openhands/runtime/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,3 +358,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 port_mapping(self) -> list[str]:
raise NotImplementedError('This method is not implemented in the base class.')
58 changes: 46 additions & 12 deletions openhands/runtime/impl/eventstream/eventstream_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ def __init__(
self.config = config
self._host_port = 30000 # initial dummy value
self._container_port = 30001 # initial dummy value
self.default_port_mapping = {4141: 4141, 4142: 4142}
self._vscode_url: str | None = None # initial dummy value
self._runtime_initialized: bool = False
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
Expand Down Expand Up @@ -230,11 +231,38 @@ 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
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)}],
}

# Dynamically add a port from ports dictionary
for container_port, host_port in self.default_port_mapping.items():
port_mapping[f'{container_port}/tcp'] = [{'HostPort': str(host_port)}]

# Add custom port mappings from config if specified
if (
hasattr(self.config.sandbox, 'port_mappings')
and self.config.sandbox.port_mappings
):
for (
container_port,
host_port,
) in self.config.sandbox.port_mappings.items():
port_mapping[f'{container_port}/tcp'] = [
{'HostPort': str(host_port)}
]

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

if use_host_network:
self.log(
Expand All @@ -250,13 +278,6 @@ def _init_container(self):
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 @@ -324,6 +345,7 @@ def _init_container(self):
'error',
f'Error: Instance {self.container_name} FAILED to start container!\n',
)
self.log('error', str(e))
except Exception as e:
self.log(
'error',
Expand Down Expand Up @@ -612,3 +634,15 @@ def vscode_url(self) -> str | None:
return self._vscode_url
else:
return None

@property
def port_mapping(self):
ports = []

for port in self.default_port_mapping.values():
ports.append(f'http://localhost:{port}')

for port in self.config.sandbox.port_mappings.values():
ports.append(f'http://localhost:{port}')

return ports
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 @@ -94,6 +94,7 @@ def __init__(
)
self.runtime_id: str | None = None
self.runtime_url: str | None = None
self.open_ports: list[str] = [] # list of open port urls
self._runtime_initialized: bool = False
self._vscode_url: str | None = None # initial dummy value

Expand Down Expand Up @@ -279,6 +280,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.open_ports = start_response.get('work_ports', [])

if 'session_api_key' in start_response:
self.session.headers.update(
{'X-Session-API-Key': start_response['session_api_key']}
Expand Down Expand Up @@ -315,6 +318,10 @@ def vscode_url(self) -> str | None:
else:
return None

@property
def port_mappings(self) -> list[str]:
return self.open_ports

def _wait_until_alive(self):
retry_decorator = tenacity.retry(
stop=tenacity.stop_after_delay(
Expand Down
28 changes: 28 additions & 0 deletions openhands/server/routes/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,34 @@ async def get_vscode_url(request: Request):
)


@app.get('/ports')
async def get_ports(request: Request):
"""Get the ports used by the runtime.

This endpoint allows getting the ports used by the runtime.

Args:
request (Request): The incoming FastAPI request object.

Returns:
JSONResponse: A JSON response indicating the success of the operation.
"""
try:
runtime: Runtime = request.state.conversation.runtime
logger.debug(f'Runtime type: {type(runtime)}')
logger.debug(f'Runtime ports: {runtime.port_mapping}')
return JSONResponse(status_code=200, content={'ports': runtime.port_mapping})
except Exception as e:
logger.error(f'Error getting runtime ports: {e}', exc_info=True)
return JSONResponse(
status_code=500,
content={
'ports': None,
'error': f'Error getting runtime ports: {e}',
},
)


@app.get('/events/search')
async def search_events(
request: Request,
Expand Down
Loading
Loading