Allow for path based runtimes in the SAAS environment (#10518)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
chuckbutkus
2025-08-25 14:31:07 -04:00
committed by GitHub
parent e7aae1495c
commit 7fbcb29499
8 changed files with 87 additions and 22 deletions

View File

@@ -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,

View File

@@ -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,
});

View File

@@ -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,
},
}));

View File

@@ -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

View File

@@ -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

View File

@@ -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}',

View File

@@ -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'
)

View File

@@ -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