From 7fbcb29499a43c5412f31e08773be6e8d2162ea1 Mon Sep 17 00:00:00 2001 From: chuckbutkus Date: Mon, 25 Aug 2025 14:31:07 -0400 Subject: [PATCH] Allow for path based runtimes in the SAAS environment (#10518) Co-authored-by: openhands --- .../conversation-subscriptions-provider.tsx | 13 ++++++++-- frontend/src/context/ws-client-provider.tsx | 15 +++++++++--- ...ate-conversation-and-subscribe-multiple.ts | 11 ++++++++- openhands/runtime/action_execution_server.py | 11 +++++++-- openhands/runtime/impl/local/local_runtime.py | 18 +++++++++----- .../runtime/impl/remote/remote_runtime.py | 15 ++++++++---- openhands/runtime/plugins/vscode/__init__.py | 24 +++++++++++++++++-- tests/unit/runtime/impl/test_local_runtime.py | 2 +- 8 files changed, 87 insertions(+), 22 deletions(-) diff --git a/frontend/src/context/conversation-subscriptions-provider.tsx b/frontend/src/context/conversation-subscriptions-provider.tsx index a4fcecc659..54a624028f 100644 --- a/frontend/src/context/conversation-subscriptions-provider.tsx +++ b/frontend/src/context/conversation-subscriptions-provider.tsx @@ -33,6 +33,7 @@ interface ConversationSubscriptionsContextType { sessionApiKey: string | null; providersSet: ("github" | "gitlab" | "bitbucket" | "enterprise_sso")[]; baseUrl: string; + socketPath?: string; onEvent?: (event: unknown, conversationId: string) => void; }) => void; unsubscribeFromConversation: (conversationId: string) => void; @@ -136,10 +137,17 @@ export function ConversationSubscriptionsProvider({ sessionApiKey: string | null; providersSet: ("github" | "gitlab" | "bitbucket" | "enterprise_sso")[]; baseUrl: string; + socketPath?: string; onEvent?: (event: unknown, conversationId: string) => void; }) => { - const { conversationId, sessionApiKey, providersSet, baseUrl, onEvent } = - options; + const { + conversationId, + sessionApiKey, + providersSet, + baseUrl, + socketPath, + onEvent, + } = options; // If already subscribed, don't create a new subscription if (conversationSockets[conversationId]) { @@ -196,6 +204,7 @@ export function ConversationSubscriptionsProvider({ // Create socket connection const socket = io(baseUrl, { transports: ["websocket"], + path: socketPath ?? "/socket.io", query: { conversation_id: conversationId, session_api_key: sessionApiKey, diff --git a/frontend/src/context/ws-client-provider.tsx b/frontend/src/context/ws-client-provider.tsx index 4bd8c5558d..8a62c351cc 100644 --- a/frontend/src/context/ws-client-provider.tsx +++ b/frontend/src/context/ws-client-provider.tsx @@ -317,15 +317,24 @@ export function WsClientProvider({ session_api_key: conversation.session_api_key, // Have to set here because socketio doesn't support custom headers. :( }; - let baseUrl = null; + let baseUrl: string | null = null; + let socketPath: string; if (conversation.url && !conversation.url.startsWith("/")) { - baseUrl = new URL(conversation.url).host; + const u = new URL(conversation.url); + baseUrl = u.host; + const pathBeforeApi = u.pathname.split("/api/conversations")[0] || "/"; + // Socket.IO server default path is /socket.io; prefix with pathBeforeApi for path mode + socketPath = `${pathBeforeApi.replace(/\/$/, "")}/socket.io`; } else { - baseUrl = import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host; + baseUrl = + (import.meta.env.VITE_BACKEND_BASE_URL as string | undefined) || + window?.location.host; + socketPath = "/socket.io"; } sio = io(baseUrl, { transports: ["websocket"], + path: socketPath, query, }); diff --git a/frontend/src/hooks/use-create-conversation-and-subscribe-multiple.ts b/frontend/src/hooks/use-create-conversation-and-subscribe-multiple.ts index 56bd665dc3..ebd5b37995 100644 --- a/frontend/src/hooks/use-create-conversation-and-subscribe-multiple.ts +++ b/frontend/src/hooks/use-create-conversation-and-subscribe-multiple.ts @@ -14,6 +14,7 @@ interface ConversationData { conversationId: string; sessionApiKey: string | null; baseUrl: string; + socketPath: string; onEventCallback?: (event: unknown, conversationId: string) => void; } @@ -83,6 +84,7 @@ export const useCreateConversationAndSubscribeMultiple = () => { sessionApiKey, providersSet: providers, baseUrl, + socketPath: conversationData.socketPath, onEvent: conversationData.onEventCallback, }); @@ -152,12 +154,18 @@ export const useCreateConversationAndSubscribeMultiple = () => { // Only handle immediate post-creation tasks here let baseUrl = ""; + let socketPath: string; if (data?.url && !data.url.startsWith("/")) { - baseUrl = new URL(data.url).host; + const u = new URL(data.url); + baseUrl = u.host; + const pathBeforeApi = + u.pathname.split("/api/conversations")[0] || "/"; + socketPath = `${pathBeforeApi.replace(/\/$/, "")}/socket.io`; } else { baseUrl = (import.meta.env.VITE_BACKEND_BASE_URL as string | undefined) || window?.location.host; + socketPath = "/socket.io"; } // Store conversation data for polling and eventual subscription @@ -167,6 +175,7 @@ export const useCreateConversationAndSubscribeMultiple = () => { conversationId: data.conversation_id, sessionApiKey: data.session_api_key, baseUrl, + socketPath, onEventCallback, }, })); diff --git a/openhands/runtime/action_execution_server.py b/openhands/runtime/action_execution_server.py index cdef8f13ad..b478fdfb03 100644 --- a/openhands/runtime/action_execution_server.py +++ b/openhands/runtime/action_execution_server.py @@ -328,7 +328,12 @@ class ActionExecutor: async def _init_plugin(self, plugin: Plugin): assert self.bash_session is not None - await plugin.initialize(self.username) + # VSCode plugin needs runtime_id for path-based routing when using Gateway API + if isinstance(plugin, VSCodePlugin): + runtime_id = os.environ.get('RUNTIME_ID') + await plugin.initialize(self.username, runtime_id=runtime_id) + else: + await plugin.initialize(self.username) self.plugins[plugin.name] = plugin logger.debug(f'Initializing plugin: {plugin.name}') @@ -876,7 +881,9 @@ if __name__ == '__main__': @app.post('/upload_file') async def upload_file( - file: UploadFile, destination: str = '/', recursive: bool = False + file: UploadFile, + destination: str = '/', + recursive: bool = False, ): assert client is not None diff --git a/openhands/runtime/impl/local/local_runtime.py b/openhands/runtime/impl/local/local_runtime.py index 208e89cf33..ee9fc7a706 100644 --- a/openhands/runtime/impl/local/local_runtime.py +++ b/openhands/runtime/impl/local/local_runtime.py @@ -575,9 +575,8 @@ class LocalRuntime(ActionExecutionClient): # TODO: This could be removed if we had a straightforward variable containing the RUNTIME_URL in the K8 env. runtime_url_pattern = os.getenv('RUNTIME_URL_PATTERN') - hostname = os.getenv('HOSTNAME') - if runtime_url_pattern and hostname: - runtime_id = hostname.split('-')[1] + runtime_id = os.getenv('RUNTIME_ID') + if runtime_url_pattern and runtime_id: runtime_url = runtime_url_pattern.format(runtime_id=runtime_id) return runtime_url @@ -586,12 +585,19 @@ class LocalRuntime(ActionExecutionClient): def _create_url(self, prefix: str, port: int) -> str: runtime_url = self.runtime_url + logger.debug(f'runtime_url is {runtime_url}') if 'localhost' in runtime_url: url = f'{self.runtime_url}:{self._vscode_port}' else: - # Similar to remote runtime... - parsed_url = urlparse(runtime_url) - url = f'{parsed_url.scheme}://{prefix}-{parsed_url.netloc}' + runtime_id = os.getenv('RUNTIME_ID') + parsed = urlparse(self.runtime_url) + scheme, netloc, path = parsed.scheme, parsed.netloc, parsed.path or '/' + path_mode = path.startswith(f'/{runtime_id}') if runtime_id else False + if path_mode: + url = f'{scheme}://{netloc}/{runtime_id}/{prefix}' + else: + url = f'{scheme}://{prefix}-{netloc}' + logger.debug(f'_create_url url is {url}') return url @property diff --git a/openhands/runtime/impl/remote/remote_runtime.py b/openhands/runtime/impl/remote/remote_runtime.py index ce52a43165..d122ca5514 100644 --- a/openhands/runtime/impl/remote/remote_runtime.py +++ b/openhands/runtime/impl/remote/remote_runtime.py @@ -379,11 +379,16 @@ class RemoteRuntime(ActionExecutionClient): token = super().get_vscode_token() if not token: return None - _parsed_url = urlparse(self.runtime_url) - assert isinstance(_parsed_url.scheme, str) and isinstance( - _parsed_url.netloc, str - ) - vscode_url = f'{_parsed_url.scheme}://vscode-{_parsed_url.netloc}/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}' + assert self.runtime_url is not None and self.runtime_id is not None + self.log('debug', f'runtime_url: {self.runtime_url}') + parsed = urlparse(self.runtime_url) + scheme, netloc, path = parsed.scheme, parsed.netloc, parsed.path or '/' + # Path mode if runtime_url path starts with /{id} + path_mode = path.startswith(f'/{self.runtime_id}') + if path_mode: + vscode_url = f'{scheme}://{netloc}/{self.runtime_id}/vscode?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}' + else: + vscode_url = f'{scheme}://vscode-{netloc}/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}' self.log( 'debug', f'VSCode URL: {vscode_url}', diff --git a/openhands/runtime/plugins/vscode/__init__.py b/openhands/runtime/plugins/vscode/__init__.py index a4f76a2815..2a94876eca 100644 --- a/openhands/runtime/plugins/vscode/__init__.py +++ b/openhands/runtime/plugins/vscode/__init__.py @@ -6,6 +6,7 @@ import uuid from dataclasses import dataclass from pathlib import Path from typing import Optional +from urllib.parse import urlparse from openhands.core.logger import openhands_logger as logger from openhands.events.action import Action @@ -26,7 +27,7 @@ class VSCodePlugin(Plugin): vscode_connection_token: Optional[str] = None gateway_process: asyncio.subprocess.Process - async def initialize(self, username: str) -> None: + async def initialize(self, username: str, runtime_id: str | None = None) -> None: # Check if we're on Windows - VSCode plugin is not supported on Windows if os.name == 'nt' or sys.platform == 'win32': self.vscode_port = None @@ -63,11 +64,30 @@ class VSCodePlugin(Plugin): ) return workspace_path = os.getenv('WORKSPACE_MOUNT_PATH_IN_SANDBOX', '/workspace') + # Compute base path for OpenVSCode Server when running behind a path-based router + base_path_flag = '' + # Allow explicit override via environment + explicit_base = os.getenv('OPENVSCODE_SERVER_BASE_PATH') + if explicit_base: + explicit_base = ( + explicit_base if explicit_base.startswith('/') else f'/{explicit_base}' + ) + base_path_flag = f' --server-base-path {explicit_base.rstrip("/")}' + else: + # If runtime_id passed explicitly (preferred), use it + runtime_url = os.getenv('RUNTIME_URL', '') + if runtime_url and runtime_id: + parsed = urlparse(runtime_url) + path = parsed.path or '/' + path_mode = path.startswith(f'/{runtime_id}') + if path_mode: + base_path_flag = f' --server-base-path /{runtime_id}/vscode' + cmd = ( f"su - {username} -s /bin/bash << 'EOF'\n" f'sudo chown -R {username}:{username} /openhands/.openvscode-server\n' f'cd {workspace_path}\n' - f'exec /openhands/.openvscode-server/bin/openvscode-server --host 0.0.0.0 --connection-token {self.vscode_connection_token} --port {self.vscode_port} --disable-workspace-trust\n' + f'exec /openhands/.openvscode-server/bin/openvscode-server --host 0.0.0.0 --connection-token {self.vscode_connection_token} --port {self.vscode_port} --disable-workspace-trust{base_path_flag}\n' 'EOF' ) diff --git a/tests/unit/runtime/impl/test_local_runtime.py b/tests/unit/runtime/impl/test_local_runtime.py index 06febd9f76..d3f62664b6 100644 --- a/tests/unit/runtime/impl/test_local_runtime.py +++ b/tests/unit/runtime/impl/test_local_runtime.py @@ -80,7 +80,7 @@ class TestLocalRuntime: env_vars = { 'RUNTIME_URL_PATTERN': 'http://runtime-{runtime_id}.example.com', - 'HOSTNAME': 'runtime-abc123-xyz', + 'RUNTIME_ID': 'abc123', } with patch.dict(os.environ, env_vars, clear=True): # Call the actual runtime_url property