mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
Allow for path based runtimes in the SAAS environment (#10518)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user