Skip to content

Commit fcca85d

Browse files
feat: Add SSH service for local VSCode Remote-SSH access
Add SSH server support to agent-server containers, enabling users to connect via their local VSCode with the Remote-SSH extension. ## Changes ### Dockerfile - Install openssh-server package - Generate SSH host keys (ed25519, rsa, ecdsa) at build time - Configure sshd for non-root operation (port 2222, key-based auth) - Set proper permissions on host keys for openhands group ### SSH Service (ssh_service.py) - New service to manage SSH server lifecycle - Populates ~/.ssh/authorized_keys from OH_SSH_PUBLIC_KEYS env var - Starts sshd as non-root user on port 2222 - Integrated with agent-server startup ### Configuration - Add SSH_PORT (default: 2222) to config - Add OH_SSH_PUBLIC_KEYS environment variable support ## Technical Details - SSH server runs on port 2222 (non-privileged) - Host keys readable by openhands group (mode 640) - PAM disabled (requires root) - Public key authentication preferred - Password authentication available as fallback Co-authored-by: openhands <openhands@all-hands.dev>
1 parent 62c2e7c commit fcca85d

File tree

4 files changed

+263
-1
lines changed

4 files changed

+263
-1
lines changed

openhands-agent-server/openhands/agent_server/api.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from openhands.agent_server.tool_router import tool_router
4040
from openhands.agent_server.vscode_router import vscode_router
4141
from openhands.agent_server.vscode_service import get_vscode_service
42+
from openhands.agent_server.ssh_service import get_ssh_service
4243
from openhands.sdk.logger import DEBUG, get_logger
4344

4445

@@ -51,6 +52,7 @@ async def api_lifespan(api: FastAPI) -> AsyncIterator[None]:
5152
vscode_service = get_vscode_service()
5253
desktop_service = get_desktop_service()
5354
tool_preload_service = get_tool_preload_service()
55+
ssh_service = get_ssh_service()
5456

5557
# Define async functions for starting each service
5658
async def start_vscode_service():
@@ -87,11 +89,22 @@ async def start_tool_preload_service():
8789
else:
8890
logger.info("Tool preload service is disabled")
8991

92+
async def start_ssh_service():
93+
if ssh_service is not None:
94+
ssh_started = await ssh_service.start()
95+
if ssh_started:
96+
logger.info("SSH service started successfully")
97+
else:
98+
logger.warning("SSH service failed to start, continuing without SSH")
99+
else:
100+
logger.info("SSH service is disabled")
101+
90102
# Start all services concurrently
91103
results = await asyncio.gather(
92104
start_vscode_service(),
93105
start_desktop_service(),
94106
start_tool_preload_service(),
107+
start_ssh_service(),
95108
return_exceptions=True,
96109
)
97110

openhands-agent-server/openhands/agent_server/config.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,16 @@ class Config(BaseModel):
152152
default=False,
153153
description="Whether to enable VNC desktop functionality",
154154
)
155+
enable_ssh: bool = Field(
156+
default=True,
157+
description="Whether to enable SSH server functionality for local VSCode Remote-SSH access",
158+
)
159+
ssh_port: int = Field(
160+
default=2222,
161+
ge=1,
162+
le=65535,
163+
description="Port on which SSH server should run",
164+
)
155165
preload_tools: bool = Field(
156166
default=True,
157167
description="Whether to preload tools",

openhands-agent-server/openhands/agent_server/docker/Dockerfile

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,11 +227,46 @@ RUN chown -R ${USERNAME}:${USERNAME} ${NOVNC_WEB}
227227
# Override default XFCE wallpaper
228228
COPY --chown=${USERNAME}:${USERNAME} openhands-agent-server/openhands/agent_server/docker/wallpaper.svg /usr/share/backgrounds/xfce/xfce-shapes.svg
229229

230+
# --- SSH Server for local VSCode Remote-SSH access ---
231+
ARG SSH_PORT=2222
232+
RUN set -eux; \
233+
apt-get update; \
234+
apt-get install -y --no-install-recommends openssh-server; \
235+
apt-get clean; rm -rf /var/lib/apt/lists/*; \
236+
# Create SSH runtime directory
237+
mkdir -p /run/sshd; \
238+
chmod 755 /run/sshd; \
239+
# Generate host keys
240+
ssh-keygen -A; \
241+
# Make host keys readable by openhands user so sshd can run without root
242+
chmod 644 /etc/ssh/ssh_host_*_key.pub; \
243+
chmod 640 /etc/ssh/ssh_host_*_key; \
244+
chgrp ${USERNAME} /etc/ssh/ssh_host_*_key; \
245+
# Configure sshd for both password and public key authentication
246+
sed -i 's/#PasswordAuthentication yes/PasswordAuthentication yes/' /etc/ssh/sshd_config; \
247+
sed -i 's/#PermitEmptyPasswords no/PermitEmptyPasswords no/' /etc/ssh/sshd_config; \
248+
sed -i 's/#PubkeyAuthentication yes/PubkeyAuthentication yes/' /etc/ssh/sshd_config; \
249+
sed -i 's/#AuthorizedKeysFile/AuthorizedKeysFile/' /etc/ssh/sshd_config; \
250+
# Configure SSH to listen on custom port (non-privileged port, no root needed)
251+
sed -i "s/#Port 22/Port ${SSH_PORT}/" /etc/ssh/sshd_config; \
252+
echo "Port ${SSH_PORT}" >> /etc/ssh/sshd_config; \
253+
# Allow sshd to run on non-privileged port without root
254+
sed -i 's/#UsePAM yes/UsePAM no/' /etc/ssh/sshd_config; \
255+
# Set password for openhands user (password: "openhands")
256+
echo "${USERNAME}:openhands" | chpasswd; \
257+
# Create SSH directory for openhands user
258+
mkdir -p /home/${USERNAME}/.ssh; \
259+
chmod 700 /home/${USERNAME}/.ssh; \
260+
touch /home/${USERNAME}/.ssh/authorized_keys; \
261+
chmod 600 /home/${USERNAME}/.ssh/authorized_keys; \
262+
chown -R ${USERNAME}:${USERNAME} /home/${USERNAME}/.ssh
263+
ENV SSH_PORT=${SSH_PORT}
264+
230265
USER ${USERNAME}
231266
WORKDIR /
232267
ENV OH_ENABLE_VNC=false
233268
ENV LOG_JSON=true
234-
EXPOSE ${PORT} ${NOVNC_PORT}
269+
EXPOSE ${PORT} ${NOVNC_PORT} ${SSH_PORT}
235270

236271

237272
####################################################################################
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
"""SSH service for managing sshd in the agent server."""
2+
3+
import asyncio
4+
import os
5+
from pathlib import Path
6+
7+
from openhands.sdk.logger import get_logger
8+
9+
10+
logger = get_logger(__name__)
11+
12+
SSH_PORT = 2222
13+
SSH_PUBLIC_KEYS_ENV = "OH_SSH_PUBLIC_KEYS"
14+
15+
16+
class SSHService:
17+
"""Service to manage SSH server startup."""
18+
19+
def __init__(self, port: int = SSH_PORT):
20+
"""Initialize SSH service.
21+
22+
Args:
23+
port: Port to run SSH server on (default: 2222)
24+
"""
25+
self.port: int = port
26+
self.process: asyncio.subprocess.Process | None = None
27+
28+
def _setup_authorized_keys(self) -> int:
29+
"""Set up authorized_keys file from environment variable.
30+
31+
Returns the number of keys added.
32+
"""
33+
ssh_keys_str = os.environ.get(SSH_PUBLIC_KEYS_ENV, "")
34+
if not ssh_keys_str:
35+
return 0
36+
37+
# SSH keys are passed as newline-separated values
38+
ssh_keys = [k.strip() for k in ssh_keys_str.split("\n") if k.strip()]
39+
if not ssh_keys:
40+
return 0
41+
42+
# SSH directory for openhands user
43+
ssh_dir = Path("/home/openhands/.ssh")
44+
45+
# Create .ssh directory if it doesn't exist
46+
ssh_dir.mkdir(parents=True, exist_ok=True)
47+
os.chmod(ssh_dir, 0o700)
48+
49+
# Write authorized_keys file
50+
authorized_keys_path = ssh_dir / "authorized_keys"
51+
with open(authorized_keys_path, "w") as f:
52+
for key in ssh_keys:
53+
f.write(f"{key}\n")
54+
55+
os.chmod(authorized_keys_path, 0o600)
56+
57+
# Try to set correct ownership (may fail if not root)
58+
try:
59+
import pwd
60+
61+
pw = pwd.getpwnam("openhands")
62+
os.chown(ssh_dir, pw.pw_uid, pw.pw_gid)
63+
os.chown(authorized_keys_path, pw.pw_uid, pw.pw_gid)
64+
except (KeyError, PermissionError):
65+
pass
66+
67+
logger.info(f"Added {len(ssh_keys)} SSH public key(s) to authorized_keys")
68+
return len(ssh_keys)
69+
70+
async def start(self) -> bool:
71+
"""Start the SSH server.
72+
73+
Returns:
74+
True if started successfully, False otherwise
75+
"""
76+
try:
77+
# Check if sshd binary exists
78+
if not self._check_sshd_available():
79+
logger.warning("sshd binary not found, SSH will be disabled")
80+
return False
81+
82+
# Check if port is available
83+
if not await self._is_port_available():
84+
logger.warning(
85+
f"Port {self.port} is not available, SSH will be disabled"
86+
)
87+
return False
88+
89+
# Set up authorized_keys from environment variable
90+
num_keys = self._setup_authorized_keys()
91+
92+
# Start sshd in the foreground (will be managed by this process)
93+
await self._start_sshd_process()
94+
95+
if num_keys > 0:
96+
logger.info(
97+
f"SSH server started on port {self.port} with {num_keys} authorized key(s). "
98+
f"Connect using: ssh -p {self.port} openhands@<host>"
99+
)
100+
else:
101+
logger.info(
102+
f"SSH server started on port {self.port}. "
103+
f"Connect using: ssh -p {self.port} openhands@<host> (password: openhands)"
104+
)
105+
return True
106+
107+
except Exception as e:
108+
logger.error(f"Failed to start SSH server: {e}")
109+
return False
110+
111+
async def stop(self) -> None:
112+
"""Stop the SSH server."""
113+
if self.process:
114+
try:
115+
self.process.terminate()
116+
await asyncio.wait_for(self.process.wait(), timeout=5.0)
117+
logger.info("SSH server stopped successfully")
118+
except TimeoutError:
119+
logger.warning("SSH server did not stop gracefully, killing process")
120+
self.process.kill()
121+
await self.process.wait()
122+
except Exception as e:
123+
logger.error(f"Error stopping SSH server: {e}")
124+
finally:
125+
self.process = None
126+
127+
def is_running(self) -> bool:
128+
"""Check if SSH server is running.
129+
130+
Returns:
131+
True if running, False otherwise
132+
"""
133+
return self.process is not None and self.process.returncode is None
134+
135+
def _check_sshd_available(self) -> bool:
136+
"""Check if sshd binary is available.
137+
138+
Returns:
139+
True if available, False otherwise
140+
"""
141+
return Path("/usr/sbin/sshd").exists()
142+
143+
async def _is_port_available(self) -> bool:
144+
"""Check if the specified port is available.
145+
146+
Returns:
147+
True if port is available, False otherwise
148+
"""
149+
try:
150+
# Try to bind to the port
151+
server = await asyncio.start_server(
152+
lambda _r, _w: None, "0.0.0.0", self.port
153+
)
154+
server.close()
155+
await server.wait_closed()
156+
return True
157+
except OSError:
158+
return False
159+
160+
async def _start_sshd_process(self) -> None:
161+
"""Start the sshd server process."""
162+
# Run sshd in foreground mode (-D) on the specified port
163+
cmd = f"/usr/sbin/sshd -D -p {self.port}"
164+
165+
# Start the process
166+
self.process = await asyncio.create_subprocess_shell(
167+
cmd,
168+
stdout=asyncio.subprocess.PIPE,
169+
stderr=asyncio.subprocess.STDOUT,
170+
)
171+
172+
# Give sshd a moment to start and check if it's still running
173+
await asyncio.sleep(0.5)
174+
175+
if self.process.returncode is not None:
176+
# Process already exited - there was an error
177+
if self.process.stdout:
178+
output = await self.process.stdout.read()
179+
logger.error(f"sshd failed to start: {output.decode()}")
180+
raise RuntimeError(f"sshd exited with code {self.process.returncode}")
181+
182+
183+
# Global SSH service instance
184+
_ssh_service: SSHService | None = None
185+
186+
187+
def get_ssh_service() -> SSHService | None:
188+
"""Get the global SSH service instance.
189+
190+
Returns:
191+
SSH service instance if enabled, None if disabled
192+
"""
193+
global _ssh_service
194+
if _ssh_service is None:
195+
from openhands.agent_server.config import get_default_config
196+
197+
config = get_default_config()
198+
199+
if not config.enable_ssh:
200+
logger.info("SSH is disabled in configuration")
201+
return None
202+
else:
203+
_ssh_service = SSHService(port=config.ssh_port)
204+
return _ssh_service

0 commit comments

Comments
 (0)