From 1e6ce4ac88d644670e615f44043e13a549efc1b6 Mon Sep 17 00:00:00 2001 From: Yuchu Date: Tue, 11 Aug 2026 02:32:26 +0800 Subject: [PATCH] Add direct dev environment connections --- mkdocs.yml | 1 + mkdocs/docs/guides/cli-api.md | 28 +- mkdocs/docs/guides/server-deployment.md | 14 +- mkdocs/docs/reference/api/python/index.md | 8 + mkdocs/docs/reference/cli/dstack/connect.md | 96 ++++ src/dstack/_internal/cli/commands/connect.py | 217 +++++++++ src/dstack/_internal/cli/main.py | 2 + src/dstack/api/__init__.py | 2 +- src/dstack/api/_public/runs.py | 297 +++++++++++- .../_internal/cli/commands/test_connect.py | 345 +++++++++++++ src/tests/api/test_direct_connect.py | 457 ++++++++++++++++++ 11 files changed, 1462 insertions(+), 5 deletions(-) create mode 100644 mkdocs/docs/reference/cli/dstack/connect.md create mode 100644 src/dstack/_internal/cli/commands/connect.py create mode 100644 src/tests/_internal/cli/commands/test_connect.py create mode 100644 src/tests/api/test_direct_connect.py diff --git a/mkdocs.yml b/mkdocs.yml index 89cd591efc..10d57b53a2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -360,6 +360,7 @@ nav: - dstack ps: docs/reference/cli/dstack/ps.md - dstack stop: docs/reference/cli/dstack/stop.md - dstack attach: docs/reference/cli/dstack/attach.md + - dstack connect: docs/reference/cli/dstack/connect.md - dstack login: docs/reference/cli/dstack/login.md - dstack logs: docs/reference/cli/dstack/logs.md - dstack metrics: docs/reference/cli/dstack/metrics.md diff --git a/mkdocs/docs/guides/cli-api.md b/mkdocs/docs/guides/cli-api.md index b451dc281b..cb708ce188 100644 --- a/mkdocs/docs/guides/cli-api.md +++ b/mkdocs/docs/guides/cli-api.md @@ -215,6 +215,33 @@ $ dstack attach <run name> Use `--job JOB_NUMBER` with `dstack attach` to attach to another job. Ports declared in the run configuration are forwarded while attached. +### Connect without attaching + +When the server has the [SSH proxy](server-deployment.md#ssh-proxy) enabled, +the owner of a running dev environment can connect without keeping an attach +process running: + +
+ +```shell +$ dstack connect <run name> +``` + +
+ +Interactive SSH is the default. Use `--ide` to open the IDE configured by the +dev environment, or `--vscode`, `--cursor`, `--windsurf`, or `--zed` to require +a specific configured IDE. + +`dstack connect` refreshes the run, validates the complete proxy connection +record, and updates a local SSH alias using the current user's built-in key. It +does not fall back to direct host SSH. Use `--expect-sshproxy HOST[:PORT]` to +require a particular proxy endpoint. + +Unlike `dstack attach`, this command does not forward ports or stream logs. See +the [`dstack connect` reference](../reference/cli/dstack/connect.md) for the +connection and validation details. + ??? info "User SSH keys" The server stores a built-in SSH key pair for each user. @@ -269,7 +296,6 @@ available in the CLI or for integrations that need to call the server directly. Out of scope for this guide: - Git repo workflows - File upload workflows -- Direct SSH/sshproxy workflows - Backward compatibility between older clients and servers --> diff --git a/mkdocs/docs/guides/server-deployment.md b/mkdocs/docs/guides/server-deployment.md index caf85f9da7..fd526ce0ca 100644 --- a/mkdocs/docs/guides/server-deployment.md +++ b/mkdocs/docs/guides/server-deployment.md @@ -372,7 +372,19 @@ This lets you: - Reach runs on SSH fleets with a head node without giving users the head node's SSH key. - Let users connect to runs without `dstack attach`. This requires uploading their public SSH key(s) to the `dstack` server. - +After the proxy is deployed and configured, the owner of a running dev +environment can open a direct SSH or IDE session without keeping `dstack +attach` running: + +```shell +dstack connect <run name> +dstack connect --ide <run name> +``` + +The command uses the current user's built-in SSH key and fails if the server +does not return a valid proxy connection. It does not forward ports or stream +logs. See the [`dstack connect` reference](../reference/cli/dstack/connect.md) +for the supported IDEs and security checks. ### Deployment diff --git a/mkdocs/docs/reference/api/python/index.md b/mkdocs/docs/reference/api/python/index.md index 96e39467e3..e6c3076aea 100644 --- a/mkdocs/docs/reference/api/python/index.md +++ b/mkdocs/docs/reference/api/python/index.md @@ -128,6 +128,14 @@ finally: show_root_toc_entry: false heading_level: 4 +### `dstack.api.RunDirectConnection` { #dstack.api.RunDirectConnection data-toc-label="RunDirectConnection" } + +::: dstack.api.RunDirectConnection + options: + show_root_heading: false + show_root_toc_entry: false + heading_level: 4 + ### `dstack.api.Resources` { #dstack.api.Resources data-toc-label="Resources" } #SCHEMA# dstack.api.Resources diff --git a/mkdocs/docs/reference/cli/dstack/connect.md b/mkdocs/docs/reference/cli/dstack/connect.md new file mode 100644 index 0000000000..6929d06f52 --- /dev/null +++ b/mkdocs/docs/reference/cli/dstack/connect.md @@ -0,0 +1,96 @@ +--- +title: connect +description: Connect directly to a running dev environment through the SSH proxy +--- + +# dstack connect + +This command connects directly to a running dev environment through the +server's configured SSH proxy. It can open an interactive SSH session or the +IDE configured by the dev environment without keeping `dstack attach` running. + +## Usage + +
+ +```shell +$ dstack connect --help +#GENERATE# +``` + +
+ +Interactive SSH is the default mode: + +```shell +dstack connect <run name> +``` + +Use `--ide` to open the IDE selected by the dev environment configuration: + +```shell +dstack connect --ide <run name> +``` + +The supported IDEs are VS Code, Cursor, Windsurf, and Zed. You can request one +explicitly with `--vscode`, `--cursor`, `--windsurf`, or `--zed`. An explicit +option fails if it does not match the run configuration. On Linux, the Zed +launcher can be named either `zed` or `zeditor`. + +Use `--replica` and `--job` to select a job. Without `--replica`, the command +uses any running replica with the selected job number. + +## Pin the SSH proxy + +Automation can require an exact proxy endpoint: + +```shell +dstack connect --expect-sshproxy proxy.example.com:2222 <run name> +``` + +The port defaults to `22`. Bracket IPv6 addresses, for example +`[2001:db8::1]:2222`. If the run reports a different endpoint, the command +fails before downloading a key, updating local SSH configuration, or launching +a client. + +## Requirements and behavior + +`dstack connect` is intentionally limited to a running dev environment owned by +the current user. The server must have the SSH proxy enabled and provide a +complete, internally consistent SSH and IDE connection record for the selected +job. The command never falls back to direct host SSH. + +The CLI validates the proxy host, port, upstream identifier, SSH command, IDE +URL, and remote working directory before changing local state. It then stores a +local `dstack-direct-*` alias in `~/.dstack/ssh/config` using the current user's +built-in SSH key. The selected job's dynamic proxy identifier stays in the +`user@alias` target, so resolving the connection again after a retry does not +reuse a stale proxy session. + +Client processes are launched with argument vectors, without a shell. Remote +paths in IDE URLs are percent-encoded before they are passed to the local IDE. + +This command does not forward ports or stream logs. Use +[`dstack attach`](attach.md) when those attached-session features are required. + +## Python API + +Use `Run.get_direct_connection()` to perform the same refresh, validation, key +setup, and SSH alias update without launching a client: + +```python +import subprocess + +from dstack.api import Client + +client = Client.from_config() +run = client.runs.get("my-dev-environment") +if run is None: + raise RuntimeError("Run not found") + +connection = run.get_direct_connection() +subprocess.run(connection.ssh_command, check=True) +``` + +`RunDirectConnection.ssh_command` and `ide_command` are argument tuples. Pass +them directly to a subprocess API; do not join them into a shell command. diff --git a/src/dstack/_internal/cli/commands/connect.py b/src/dstack/_internal/cli/commands/connect.py new file mode 100644 index 0000000000..a36ac6d6dd --- /dev/null +++ b/src/dstack/_internal/cli/commands/connect.py @@ -0,0 +1,217 @@ +import argparse +import shutil +import subprocess +from typing import Literal +from urllib.parse import urlsplit + +from dstack._internal.cli.commands import APIBaseCommand +from dstack._internal.cli.services.completion import RunNameCompleter +from dstack._internal.core.errors import CLIError +from dstack._internal.utils.ssh import find_ssh_client +from dstack.api import Client, RunDirectConnection + +ConnectionMode = Literal["ssh", "ide", "vscode", "cursor", "windsurf", "zed"] + +_IDE_EXECUTABLES = { + "vscode": ("code",), + "cursor": ("cursor",), + "windsurf": ("windsurf",), + "zed": ("zed", "zeditor"), +} + +_IDE_NAMES = { + "vscode": "VS Code", + "cursor": "Cursor", + "windsurf": "Windsurf", + "zed": "Zed", +} + + +def sshproxy_endpoint(value: str) -> tuple[str, int]: + """Parse HOST[:PORT], including bracketed IPv6, without resolving it.""" + if "%" in value or any(c.isspace() or c == "\0" for c in value): + raise argparse.ArgumentTypeError("Invalid SSH proxy endpoint") + parsed = urlsplit(f"ssh://{value}") + if ( + parsed.hostname is None + or parsed.username is not None + or parsed.password is not None + or parsed.path + or parsed.query + or parsed.fragment + ): + raise argparse.ArgumentTypeError("Expected SSH proxy endpoint in HOST[:PORT] format") + try: + parsed_port = parsed.port + except ValueError as e: + raise argparse.ArgumentTypeError("Invalid SSH proxy port") from e + if parsed_port is None: + if value.endswith(":"): + raise argparse.ArgumentTypeError("Invalid SSH proxy port") + port = 22 + else: + port = parsed_port + if not 1 <= port <= 65535: + raise argparse.ArgumentTypeError("Invalid SSH proxy port") + return parsed.hostname, port + + +def resolve_direct_connection( + api: Client, + run_name: str, + *, + replica_num: int | None = None, + job_num: int = 0, + expected_sshproxy: tuple[str, int] | None = None, +) -> RunDirectConnection: + """Resolve and validate a run's direct SSH-proxy connection.""" + run = api.runs.get(run_name) + if run is None: + raise CLIError(f"Run {run_name} not found") + + expected_hostname = None + expected_port = None + if expected_sshproxy is not None: + expected_hostname, expected_port = expected_sshproxy + return run.get_direct_connection( + replica_num=replica_num, + job_num=job_num, + expected_sshproxy_hostname=expected_hostname, + expected_sshproxy_port=expected_port, + ) + + +def launch_direct_connection( + connection: RunDirectConnection, + *, + mode: ConnectionMode = "ssh", +) -> None: + """Launch a validated direct connection without invoking a shell.""" + if mode == "ssh": + command = list(connection.ssh_command) + if not command or command[0] != "ssh": + raise CLIError("The direct SSH command is invalid") + ssh_client = find_ssh_client() + if ssh_client is None: + raise CLIError("SSH client not found") + executable = str(ssh_client) + client_name = "SSH" + else: + requested_ide = None if mode == "ide" else mode + if requested_ide is not None and requested_ide not in _IDE_EXECUTABLES: + raise CLIError(f"Unsupported IDE: {requested_ide}") + if connection.ide is None or connection.ide_command is None: + if requested_ide is None: + raise CLIError("The dev environment is not configured with an IDE") + raise CLIError( + f"The dev environment is not configured for {_IDE_NAMES[requested_ide]}" + ) + if requested_ide is not None and connection.ide != requested_ide: + requested_name = _IDE_NAMES[requested_ide] + configured_name = connection.ide_name or connection.ide + raise CLIError( + f"The dev environment is configured for {configured_name}, not {requested_name}" + ) + + executable_names = _IDE_EXECUTABLES.get(connection.ide) + if executable_names is None: + raise CLIError(f"Unsupported configured IDE: {connection.ide}") + command = list(connection.ide_command) + if not command or command[0] != executable_names[0]: + raise CLIError("The direct IDE command is invalid") + executable = next( + (path for name in executable_names if (path := shutil.which(name)) is not None), + None, + ) + client_name = connection.ide_name or _IDE_NAMES[connection.ide] + if executable is None: + names = " or ".join(f"`{name}`" for name in executable_names) + raise CLIError( + f"{client_name} CLI not found. Install the {names} command and try again" + ) + + command[0] = executable + result = subprocess.run(command, check=False) + if result.returncode != 0: + raise CLIError(f"{client_name} client exited with status {result.returncode}") + + +class ConnectCommand(APIBaseCommand): + NAME = "connect" + DESCRIPTION = "Connect directly to a running dev environment" + + def _register(self): + super()._register() + mode = self._parser.add_mutually_exclusive_group() + mode.add_argument( + "--ssh", + action="store_const", + const="ssh", + dest="mode", + help="Open an interactive SSH shell (default)", + ) + mode.add_argument( + "--ide", + action="store_const", + const="ide", + dest="mode", + help="Open the configured IDE", + ) + mode.add_argument( + "--vscode", + action="store_const", + const="vscode", + dest="mode", + help="Open the dev environment in VS Code", + ) + mode.add_argument( + "--cursor", + action="store_const", + const="cursor", + dest="mode", + help="Open the dev environment in Cursor", + ) + mode.add_argument( + "--windsurf", + action="store_const", + const="windsurf", + dest="mode", + help="Open the dev environment in Windsurf", + ) + mode.add_argument( + "--zed", + action="store_const", + const="zed", + dest="mode", + help="Open the dev environment in Zed", + ) + self._parser.set_defaults(mode="ssh") + self._parser.add_argument( + "--replica", + help="The replica number. Defaults to any running replica.", + type=int, + ) + self._parser.add_argument( + "--job", + help="The job number inside the replica. Defaults to 0.", + type=int, + default=0, + ) + self._parser.add_argument( + "--expect-sshproxy", + metavar="HOST[:PORT]", + type=sshproxy_endpoint, + help="Fail unless the run uses this exact SSH proxy endpoint", + ) + self._parser.add_argument("run_name").completer = RunNameCompleter() # type: ignore[attr-defined] + + def _command(self, args: argparse.Namespace): + super()._command(args) + connection = resolve_direct_connection( + self.api, + args.run_name, + replica_num=args.replica, + job_num=args.job, + expected_sshproxy=args.expect_sshproxy, + ) + launch_direct_connection(connection, mode=args.mode) diff --git a/src/dstack/_internal/cli/main.py b/src/dstack/_internal/cli/main.py index 98e3b70f5c..95351d60ed 100644 --- a/src/dstack/_internal/cli/main.py +++ b/src/dstack/_internal/cli/main.py @@ -7,6 +7,7 @@ from dstack._internal.cli.commands.apply import ApplyCommand from dstack._internal.cli.commands.attach import AttachCommand from dstack._internal.cli.commands.completion import CompletionCommand +from dstack._internal.cli.commands.connect import ConnectCommand from dstack._internal.cli.commands.delete import DeleteCommand from dstack._internal.cli.commands.event import EventCommand from dstack._internal.cli.commands.export import ExportCommand @@ -67,6 +68,7 @@ def main(): subparsers = parser.add_subparsers(metavar="COMMAND") ApplyCommand.register(subparsers) AttachCommand.register(subparsers) + ConnectCommand.register(subparsers) DeleteCommand.register(subparsers) EventCommand.register(subparsers) ExportCommand.register(subparsers) diff --git a/src/dstack/api/__init__.py b/src/dstack/api/__init__.py index 0e6c6ebe3c..428415c1a3 100644 --- a/src/dstack/api/__init__.py +++ b/src/dstack/api/__init__.py @@ -23,7 +23,7 @@ from dstack._internal.core.services.ssh.ports import PortUsedError from dstack.api._public import BackendCollection, Client, RepoCollection, RunCollection from dstack.api._public.backends import Backend -from dstack.api._public.runs import Run, RunStatus +from dstack.api._public.runs import Run, RunDirectConnection, RunStatus Service = _ServiceConfiguration Task = _TaskConfiguration diff --git a/src/dstack/api/_public/runs.py b/src/dstack/api/_public/runs.py index 3fe274c98d..dbb4554085 100644 --- a/src/dstack/api/_public/runs.py +++ b/src/dstack/api/_public/runs.py @@ -1,5 +1,8 @@ import base64 +import hashlib +import ipaddress import queue +import re import tempfile import threading import time @@ -7,10 +10,11 @@ from collections.abc import Iterator from contextlib import contextmanager from copy import copy +from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import BinaryIO, Dict, Iterable, List, Optional, Union -from urllib.parse import urlencode, urlparse +from urllib.parse import quote, urlencode, urlparse from websocket import WebSocketApp @@ -32,6 +36,7 @@ from dstack._internal.core.models.repos.virtual import VirtualRepo from dstack._internal.core.models.runs import ( Job, + JobConnectionInfo, JobSpec, JobStatus, RunPlan, @@ -49,12 +54,51 @@ from dstack._internal.utils.common import get_or_error, make_proxy_url from dstack._internal.utils.files import create_file_archive from dstack._internal.utils.logging import get_logger -from dstack._internal.utils.path import PathLike +from dstack._internal.utils.path import FilePath, PathLike +from dstack._internal.utils.ssh import ( + build_ssh_command, + build_ssh_url_authority, + include_ssh_config, + update_ssh_config, +) from dstack.api.server import APIClient logger = get_logger(__name__) +@dataclass(frozen=True) +class RunDirectConnection: + """A validated, proxy-only connection to a running dev environment. + + The command values are argument vectors and are safe to pass directly to + :mod:`subprocess` without invoking a shell. The local SSH alias is refreshed + for every resolution, so a retried job uses the current proxy upstream ID. + """ + + run_name: str + replica_num: int + job_num: int + upstream_id: str + sshproxy_hostname: str + sshproxy_port: int + ssh_alias: str + ssh_command: tuple[str, ...] + ide: Optional[str] + ide_name: Optional[str] + ide_command: Optional[tuple[str, ...]] + + +@dataclass(frozen=True) +class ValidatedDirectConnectionInfo: + """Server-provided direct connection data validated without local side effects.""" + + hostname: str + port: int + upstream_id: str + ide_name: Optional[str] + ide_command: Optional[tuple[str, ...]] + + class Run(ABC): """ Attributes: @@ -408,6 +452,99 @@ def attach( return True + def get_direct_connection( + self, + replica_num: Optional[int] = None, + job_num: int = 0, + expected_sshproxy_hostname: Optional[str] = None, + expected_sshproxy_port: Optional[int] = None, + ) -> RunDirectConnection: + """Prepare a direct SSH-proxy connection to a running dev environment. + + The run is refreshed on every call. Direct connections are owner-only, + require a running dev environment, and never fall back to legacy host SSH. + The returned commands are argument vectors; callers must not join them + into a shell command. + + Args: + replica_num: Replica number, or ``None`` for any running replica. + job_num: Job number inside the replica. + expected_sshproxy_hostname: Require this exact SSH proxy hostname when set. + expected_sshproxy_port: Require this SSH proxy port. Defaults to 22 when an expected + hostname is set. + + Returns: + A validated connection with SSH and, when configured, IDE commands. + + Raises: + dstack.api.ClientError: If the run cannot be connected to safely. + """ + self.refresh() + if self.status != RunStatus.RUNNING: + raise ClientError( + f"Direct connection requires a running run; {self.name} is {self.status.value}" + ) + if self._run.run_spec.configuration.type != "dev-environment": + raise ClientError("Direct connection is only supported for dev environments") + + current_user = self._api_client.users.get_my_user() + if self._run.user != current_user.username: + raise ClientError("Direct connection is only available to the run owner") + + job = self._find_job(replica_num=replica_num, job_num=job_num) + if job is None or not job.job_submissions: + replica_repr = replica_num if replica_num is not None else "" + raise ClientError(f"Failed to find running replica={replica_repr} job={job_num}") + if job.job_submissions[-1].status != JobStatus.RUNNING: + replica_repr = replica_num if replica_num is not None else "" + raise ClientError(f"Failed to find running replica={replica_repr} job={job_num}") + + ssh_alias = _get_direct_connection_alias( + server_url=self._api_client.base_url, + project=self._project, + token_hash=self._api_client.get_token_hash(), + run_name=self.name, + ) + ide = getattr(self._run.run_spec.configuration, "ide", None) + validated = validate_direct_connection_info( + job=job, + ssh_alias=ssh_alias, + ide=ide, + expected_sshproxy_hostname=expected_sshproxy_hostname, + expected_sshproxy_port=expected_sshproxy_port, + ) + + # Do not modify local SSH state until all server-provided connection + # fields, including the optional IDE URL, have been validated. + config_manager = ConfigManager() + user_key = UserSSHKeyManager( + self._api_client, config_manager.dstack_ssh_dir + ).get_user_key() + include_ssh_config(config_manager.dstack_ssh_config_path) + update_ssh_config( + config_manager.dstack_ssh_config_path, + ssh_alias, + { + "HostName": validated.hostname, + "Port": validated.port, + "IdentityFile": FilePath(user_key.private_key_path), + "IdentitiesOnly": "yes", + }, + ) + return RunDirectConnection( + run_name=self.name, + replica_num=job.job_spec.replica_num, + job_num=job.job_spec.job_num, + upstream_id=validated.upstream_id, + sshproxy_hostname=validated.hostname, + sshproxy_port=validated.port, + ssh_alias=ssh_alias, + ssh_command=("ssh", f"{validated.upstream_id}@{ssh_alias}"), + ide=ide, + ide_name=validated.ide_name, + ide_command=validated.ide_command, + ) + def detach(self): """ Stop the SSH tunnel to the instance and update SSH config @@ -746,6 +883,162 @@ def _reserve_ports( return PortsLock(ports).acquire() +_HOSTNAME_RE = re.compile( + r"(?=.{1,253}\.?$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*" + r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.?" +) + + +def _validate_sshproxy_hostname(hostname: Optional[str]) -> str: + if hostname is None or "%" in hostname: + raise ClientError("The server returned an invalid SSH proxy hostname") + try: + ipaddress.ip_address(hostname) + except ValueError: + if _HOSTNAME_RE.fullmatch(hostname) is None: + raise ClientError("The server returned an invalid SSH proxy hostname") + return hostname + + +def _validate_sshproxy_port(port: Optional[int]) -> int: + if port is None: + return 22 + if isinstance(port, bool) or not 1 <= port <= 65535: + raise ClientError("The server returned an invalid SSH proxy port") + return port + + +def _validate_sshproxy_upstream_id(upstream_id: Optional[str]) -> str: + # The server contract deliberately treats this value as an extensible string. + # Keep the accepted subset safe for both OpenSSH's `user@host` syntax and URL userinfo. + if ( + upstream_id is None + or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", upstream_id) is None + ): + raise ClientError("The server returned an invalid SSH proxy upstream ID") + return upstream_id + + +def _get_direct_connection_alias( + *, server_url: str, project: str, token_hash: str, run_name: str +) -> str: + target = f"{server_url}\0{project}\0{token_hash}\0{run_name}".encode() + digest = hashlib.sha256(target).hexdigest()[:24] + return f"dstack-direct-{digest}" + + +def validate_direct_connection_info( + *, + job: Job, + ssh_alias: str, + ide: Optional[str], + expected_sshproxy_hostname: Optional[str] = None, + expected_sshproxy_port: Optional[int] = None, +) -> ValidatedDirectConnectionInfo: + """Validate direct connection metadata without API calls or local state changes.""" + if not job.job_submissions or job.job_submissions[-1].status != JobStatus.RUNNING: + raise ClientError("Direct connection requires a running job") + connection_info = job.job_connection_info + if connection_info is None: + raise ClientError("The server did not provide SSH proxy connection information") + hostname = _validate_sshproxy_hostname(connection_info.sshproxy_hostname) + port = _validate_sshproxy_port(connection_info.sshproxy_port) + if expected_sshproxy_hostname is None and expected_sshproxy_port is not None: + raise ClientError("An expected SSH proxy port requires an expected hostname") + if expected_sshproxy_hostname is not None: + expected_hostname = _validate_sshproxy_hostname(expected_sshproxy_hostname) + expected_port = _validate_sshproxy_port(expected_sshproxy_port) + if hostname != expected_hostname or port != expected_port: + raise ClientError("The run SSH proxy does not match the expected endpoint") + upstream_id = _validate_sshproxy_upstream_id(connection_info.sshproxy_upstream_id) + expected_proxy_command = build_ssh_command( + username=upstream_id, + hostname=hostname, + port=connection_info.sshproxy_port, + ) + if connection_info.proxied_ssh_command != expected_proxy_command: + raise ClientError("The server returned inconsistent SSH proxy connection information") + ide_name, ide_command = _get_ide_connection( + job=job, + connection_info=connection_info, + hostname=hostname, + port=connection_info.sshproxy_port, + upstream_id=upstream_id, + ssh_alias=ssh_alias, + ide=ide, + ) + return ValidatedDirectConnectionInfo( + hostname=hostname, + port=port, + upstream_id=upstream_id, + ide_name=ide_name, + ide_command=ide_command, + ) + + +_IDE_NAMES = { + "vscode": "VS Code", + "cursor": "Cursor", + "windsurf": "Windsurf", + "zed": "Zed", +} + +_IDE_EXECUTABLES = { + "vscode": "code", + "cursor": "cursor", + "windsurf": "windsurf", + "zed": "zed", +} + + +def _get_ide_connection( + *, + job: Job, + connection_info: JobConnectionInfo, + hostname: str, + port: Optional[int], + upstream_id: str, + ssh_alias: str, + ide: Optional[str], +) -> tuple[Optional[str], Optional[tuple[str, ...]]]: + if ide is None: + if connection_info.ide_name is not None or connection_info.proxied_ide_url is not None: + raise ClientError("The server returned unexpected IDE connection information") + return None, None + ide_name = _IDE_NAMES.get(ide) + executable = _IDE_EXECUTABLES.get(ide) + if ide_name is None or executable is None: + raise ClientError(f"Direct connection does not support the configured IDE: {ide}") + + submission = job.job_submissions[-1] + runtime_data = submission.job_runtime_data + if runtime_data is None or runtime_data.working_dir is None: + raise ClientError("The server did not provide the dev environment working directory") + working_dir = runtime_data.working_dir + if not working_dir.startswith("/") or any(c in working_dir for c in ("\0", "\r", "\n")): + raise ClientError("The server returned an invalid dev environment working directory") + authority = build_ssh_url_authority( + username=upstream_id, + hostname=hostname, + port=port, + ) + if ide == "zed": + expected_url = f"zed://ssh/{authority}{working_dir}" + else: + expected_url = f"{ide}://vscode-remote/ssh-remote+{authority}{working_dir}" + if connection_info.ide_name != ide_name or connection_info.proxied_ide_url != expected_url: + raise ClientError(f"The server returned inconsistent {ide_name} connection information") + + # Keep the dynamic upstream ID in the local URL so a retried job cannot + # reuse a stale remote session keyed only by the stable SSH host alias. + encoded_working_dir = quote(working_dir, safe="/~:@-._") + alias_authority = build_ssh_url_authority(username=upstream_id, hostname=ssh_alias) + if ide == "zed": + return ide_name, (executable, f"ssh://{alias_authority}{encoded_working_dir}") + folder_uri = f"vscode-remote://ssh-remote+{alias_authority}{encoded_working_dir}" + return ide_name, (executable, "--folder-uri", folder_uri) + + @contextmanager def _prepare_code_file(repo: Repo) -> Iterator[tuple[BinaryIO, str]]: with tempfile.TemporaryFile("w+b") as fp: diff --git a/src/tests/_internal/cli/commands/test_connect.py b/src/tests/_internal/cli/commands/test_connect.py new file mode 100644 index 0000000000..b039c6b7a8 --- /dev/null +++ b/src/tests/_internal/cli/commands/test_connect.py @@ -0,0 +1,345 @@ +import argparse +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import pytest + +from dstack._internal.cli.commands.connect import ( + ConnectCommand, + launch_direct_connection, + sshproxy_endpoint, +) +from dstack._internal.core.errors import CLIError +from dstack.api import RunDirectConnection + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers() + ConnectCommand.register(subparsers) + return parser + + +def _invoke(args: list[str], run: Mock): + client = Mock() + client.runs.get.return_value = run + parsed_args = _parser().parse_args(["connect", "--project", "main", *args]) + parsed_args.extra_args = [] + with patch("dstack._internal.cli.commands.Client.from_config", return_value=client): + parsed_args.func(parsed_args) + return client + + +def _connection( + *, + ide: str | None = "zed", + ide_name: str | None = "Zed", + ide_command: tuple[str, ...] | None = ( + "zed", + "ssh://upstream@dstack-direct-safealias/workspace", + ), + ssh_command: tuple[str, ...] = ("ssh", "upstream@dstack-direct-safealias"), +) -> RunDirectConnection: + return RunDirectConnection( + run_name="dev-run", + replica_num=0, + job_num=0, + upstream_id="upstream", + sshproxy_hostname="sshproxy.example.com", + sshproxy_port=22, + ssh_alias="dstack-direct-safealias", + ssh_command=ssh_command, + ide=ide, + ide_name=ide_name, + ide_command=ide_command, + ) + + +class TestConnectCommand: + def test_launches_ssh_with_an_argument_vector(self, monkeypatch: pytest.MonkeyPatch) -> None: + run = Mock() + run.get_direct_connection.return_value = _connection() + subprocess_run = Mock(return_value=SimpleNamespace(returncode=0)) + monkeypatch.setattr( + "dstack._internal.cli.commands.connect.find_ssh_client", + lambda: "/usr/bin/ssh", + ) + monkeypatch.setattr( + "dstack._internal.cli.commands.connect.subprocess.run", + subprocess_run, + ) + + client = _invoke( + [ + "--ssh", + "--replica", + "2", + "--job", + "1", + "--expect-sshproxy", + "sshproxy.example.com:2222", + "dev-run", + ], + run, + ) + + client.runs.get.assert_called_once_with("dev-run") + run.get_direct_connection.assert_called_once_with( + replica_num=2, + job_num=1, + expected_sshproxy_hostname="sshproxy.example.com", + expected_sshproxy_port=2222, + ) + subprocess_run.assert_called_once_with( + ["/usr/bin/ssh", "upstream@dstack-direct-safealias"], + check=False, + ) + + @pytest.mark.parametrize( + ("flag", "ide", "ide_name", "ide_command", "expected_command"), + [ + ( + "--vscode", + "vscode", + "VS Code", + ( + "code", + "--folder-uri", + "vscode-remote://ssh-remote+upstream@safealias/workspace", + ), + [ + "/usr/local/bin/code", + "--folder-uri", + "vscode-remote://ssh-remote+upstream@safealias/workspace", + ], + ), + ( + "--cursor", + "cursor", + "Cursor", + ( + "cursor", + "--folder-uri", + "vscode-remote://ssh-remote+upstream@safealias/workspace", + ), + [ + "/usr/local/bin/cursor", + "--folder-uri", + "vscode-remote://ssh-remote+upstream@safealias/workspace", + ], + ), + ( + "--windsurf", + "windsurf", + "Windsurf", + ( + "windsurf", + "--folder-uri", + "vscode-remote://ssh-remote+upstream@safealias/workspace", + ), + [ + "/usr/local/bin/windsurf", + "--folder-uri", + "vscode-remote://ssh-remote+upstream@safealias/workspace", + ], + ), + ( + "--zed", + "zed", + "Zed", + ("zed", "ssh://upstream@safealias/workspace"), + ["/usr/local/bin/zed", "ssh://upstream@safealias/workspace"], + ), + ], + ) + def test_launches_an_explicit_ide_with_an_argument_vector( + self, + flag: str, + ide: str, + ide_name: str, + ide_command: tuple[str, ...], + expected_command: list[str], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + run = Mock() + run.get_direct_connection.return_value = _connection( + ide=ide, + ide_name=ide_name, + ide_command=ide_command, + ) + subprocess_run = Mock(return_value=SimpleNamespace(returncode=0)) + monkeypatch.setattr( + "dstack._internal.cli.commands.connect.shutil.which", + lambda executable: f"/usr/local/bin/{executable}", + ) + monkeypatch.setattr( + "dstack._internal.cli.commands.connect.subprocess.run", + subprocess_run, + ) + + _invoke([flag, "dev-run"], run) + + subprocess_run.assert_called_once_with(expected_command, check=False) + + def test_launches_the_configured_ide(self, monkeypatch: pytest.MonkeyPatch) -> None: + run = Mock() + run.get_direct_connection.return_value = _connection( + ide="cursor", + ide_name="Cursor", + ide_command=( + "cursor", + "--folder-uri", + "vscode-remote://ssh-remote+upstream@safealias/workspace", + ), + ) + subprocess_run = Mock(return_value=SimpleNamespace(returncode=0)) + monkeypatch.setattr( + "dstack._internal.cli.commands.connect.shutil.which", + lambda executable: f"/usr/local/bin/{executable}", + ) + monkeypatch.setattr( + "dstack._internal.cli.commands.connect.subprocess.run", + subprocess_run, + ) + + _invoke(["--ide", "dev-run"], run) + + subprocess_run.assert_called_once_with( + [ + "/usr/local/bin/cursor", + "--folder-uri", + "vscode-remote://ssh-remote+upstream@safealias/workspace", + ], + check=False, + ) + + def test_rejects_an_explicit_ide_that_is_not_configured(self) -> None: + run = Mock() + run.get_direct_connection.return_value = _connection( + ide=None, + ide_name=None, + ide_command=None, + ) + + with pytest.raises(CLIError, match="not configured for Zed"): + _invoke(["--zed", "dev-run"], run) + + def test_rejects_configured_ide_mode_for_an_ssh_only_run(self) -> None: + run = Mock() + run.get_direct_connection.return_value = _connection( + ide=None, + ide_name=None, + ide_command=None, + ) + + with pytest.raises(CLIError, match="not configured with an IDE"): + _invoke(["--ide", "dev-run"], run) + + def test_uses_zeditor_when_zed_binary_name_is_unavailable( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + run = Mock() + run.get_direct_connection.return_value = _connection( + ide_command=("zed", "ssh://upstream@safealias/workspace"), + ) + subprocess_run = Mock(return_value=SimpleNamespace(returncode=0)) + monkeypatch.setattr( + "dstack._internal.cli.commands.connect.shutil.which", + lambda executable: "/usr/bin/zeditor" if executable == "zeditor" else None, + ) + monkeypatch.setattr( + "dstack._internal.cli.commands.connect.subprocess.run", + subprocess_run, + ) + + _invoke(["--zed", "dev-run"], run) + + subprocess_run.assert_called_once_with( + ["/usr/bin/zeditor", "ssh://upstream@safealias/workspace"], + check=False, + ) + + def test_rejects_an_explicit_ide_that_does_not_match_the_configuration(self) -> None: + run = Mock() + run.get_direct_connection.return_value = _connection() + + with pytest.raises(CLIError, match="configured for Zed, not Cursor"): + _invoke(["--cursor", "dev-run"], run) + + def test_rejects_a_missing_run(self) -> None: + with pytest.raises(CLIError, match="Run missing not found"): + _invoke(["missing"], None) + + def test_rejects_an_invalid_ssh_command(self) -> None: + connection = _connection(ssh_command=("other", "unsafe")) + + with pytest.raises(CLIError, match="direct SSH command is invalid"): + launch_direct_connection(connection) + + def test_rejects_an_invalid_ide_command(self) -> None: + connection = _connection(ide_command=("other", "unsafe")) + + with pytest.raises(CLIError, match="direct IDE command is invalid"): + launch_direct_connection(connection, mode="zed") + + def test_rejects_nonzero_client_exit(self, monkeypatch: pytest.MonkeyPatch) -> None: + run = Mock() + run.get_direct_connection.return_value = _connection( + ide=None, + ide_name=None, + ide_command=None, + ) + monkeypatch.setattr( + "dstack._internal.cli.commands.connect.find_ssh_client", + lambda: "/usr/bin/ssh", + ) + monkeypatch.setattr( + "dstack._internal.cli.commands.connect.subprocess.run", + lambda *_args, **_kwargs: SimpleNamespace(returncode=255), + ) + + with pytest.raises(CLIError, match="status 255"): + _invoke(["dev-run"], run) + + def test_help_uses_standard_project_and_run_shape( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + with pytest.raises(SystemExit) as exc_info: + _parser().parse_args(["connect", "--help"]) + + assert exc_info.value.code == 0 + output = capsys.readouterr().out + assert "--project NAME" in output + for option in ("--ssh", "--ide", "--vscode", "--cursor", "--windsurf", "--zed"): + assert option in output + assert "--expect-sshproxy HOST[:PORT]" in output + assert "run_name" in output + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("sshproxy.example.com", ("sshproxy.example.com", 22)), + ("sshproxy.example.com:2222", ("sshproxy.example.com", 2222)), + ("[2001:db8::1]:2222", ("2001:db8::1", 2222)), + ], +) +def test_sshproxy_endpoint(value: str, expected: tuple[str, int]) -> None: + assert sshproxy_endpoint(value) == expected + + +@pytest.mark.parametrize( + "value", + [ + "user@host", + "host/path", + "host:0", + "host:", + "host:70000", + "host\nother", + "[fe80::1%h]:22", + ], +) +def test_sshproxy_endpoint_rejects_invalid_value(value: str) -> None: + with pytest.raises(argparse.ArgumentTypeError): + sshproxy_endpoint(value) diff --git a/src/tests/api/test_direct_connect.py b/src/tests/api/test_direct_connect.py new file mode 100644 index 0000000000..d746f8314a --- /dev/null +++ b/src/tests/api/test_direct_connect.py @@ -0,0 +1,457 @@ +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock +from uuid import UUID, uuid4 + +import pytest + +from dstack._internal.core.errors import ClientError +from dstack._internal.core.models.runs import JobConnectionInfo, JobStatus, RunStatus +from dstack._internal.core.services.ssh.key_manager import UserSSHKey +from dstack._internal.utils.path import FilePath +from dstack._internal.utils.ssh import build_ssh_command, build_ssh_url_authority +from dstack.api._public import runs as public_runs +from dstack.api._public.runs import Run + +_IDE_NAMES = { + "vscode": "VS Code", + "cursor": "Cursor", + "windsurf": "Windsurf", + "zed": "Zed", +} + + +def _run_model( + *, + job_id: UUID, + run_name: str = "dev-run", + upstream_id: str | None = None, + owner: str = "owner", + status: RunStatus = RunStatus.RUNNING, + job_status: JobStatus = JobStatus.RUNNING, + configuration_type: str = "dev-environment", + ide: str | None = "zed", + hostname: str = "sshproxy.example.com", + port: int | None = 2222, + proxied_ssh_command: list[str] | None = None, + proxied_ide_url: str | None = None, + working_dir: str = "/workspace/project", +): + if upstream_id is None: + upstream_id = job_id.hex + if proxied_ssh_command is None: + proxied_ssh_command = build_ssh_command( + username=upstream_id, + hostname=hostname, + port=port, + ) + ide_name = _IDE_NAMES.get(ide) if ide is not None else None + if proxied_ide_url is None and ide is not None: + authority = build_ssh_url_authority( + username=upstream_id, + hostname=hostname, + port=port, + ) + if ide == "zed": + proxied_ide_url = f"zed://ssh/{authority}{working_dir}" + else: + proxied_ide_url = f"{ide}://vscode-remote/ssh-remote+{authority}{working_dir}" + connection_info = JobConnectionInfo( + ide_name=ide_name, + attached_ide_url=None, + proxied_ide_url=proxied_ide_url, + attached_ssh_command=["ssh", "ignored"], + proxied_ssh_command=proxied_ssh_command, + sshproxy_hostname=hostname, + sshproxy_port=port, + sshproxy_upstream_id=upstream_id, + ) + job = SimpleNamespace( + job_spec=SimpleNamespace(replica_num=0, job_num=0), + job_submissions=[ + SimpleNamespace( + status=job_status, + job_runtime_data=SimpleNamespace(working_dir=working_dir), + ) + ], + job_connection_info=connection_info, + ) + return SimpleNamespace( + run_spec=SimpleNamespace( + run_name=run_name, + configuration=SimpleNamespace(type=configuration_type, ide=ide), + ), + jobs=[job], + status=status, + user=owner, + ) + + +def _api_client(run_model, *, username: str = "owner"): + return SimpleNamespace( + base_url="https://dstack.example.com", + get_token_hash=lambda: "token-hash", + runs=SimpleNamespace(get=Mock(return_value=run_model)), + users=SimpleNamespace(get_my_user=Mock(return_value=SimpleNamespace(username=username))), + ) + + +@pytest.fixture +def local_connection_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): + config_manager = SimpleNamespace( + dstack_ssh_dir=tmp_path, + dstack_ssh_config_path=tmp_path / "config", + ) + config_manager_factory = Mock(return_value=config_manager) + key_manager = Mock() + key_manager.return_value.get_user_key.return_value = UserSSHKey( + public_key="ssh-ed25519 public", + private_key_path=tmp_path / "private-key", + ) + include = Mock() + update = Mock() + monkeypatch.setattr(public_runs, "ConfigManager", config_manager_factory) + monkeypatch.setattr(public_runs, "UserSSHKeyManager", key_manager) + monkeypatch.setattr(public_runs, "include_ssh_config", include) + monkeypatch.setattr(public_runs, "update_ssh_config", update) + return SimpleNamespace( + config_manager_factory=config_manager_factory, + key_manager=key_manager, + include=include, + update=update, + ) + + +def _assert_no_local_connection_state(state: SimpleNamespace) -> None: + state.config_manager_factory.assert_not_called() + state.key_manager.assert_not_called() + state.include.assert_not_called() + state.update.assert_not_called() + + +class TestRunGetDirectConnection: + def test_returns_validated_argv_and_configures_current_job_alias( + self, + local_connection_config: SimpleNamespace, + ) -> None: + job_id = uuid4() + run_model = _run_model(job_id=job_id) + api_client = _api_client(run_model) + run = Run(api_client=api_client, project="main", run=run_model) + + connection = run.get_direct_connection() + + assert connection.upstream_id == job_id.hex + assert connection.ssh_command == ("ssh", f"{job_id.hex}@{connection.ssh_alias}") + assert connection.ide == "zed" + assert connection.ide_name == "Zed" + assert connection.ide_command == ( + "zed", + f"ssh://{job_id.hex}@{connection.ssh_alias}/workspace/project", + ) + _, alias, raw_options = local_connection_config.update.call_args.args + assert alias == connection.ssh_alias + options = dict(raw_options) + identity_file = options.pop("IdentityFile") + assert isinstance(identity_file, FilePath) + assert identity_file.path == ( + local_connection_config.config_manager_factory.return_value.dstack_ssh_dir + / "private-key" + ) + assert options == { + "HostName": "sshproxy.example.com", + "Port": 2222, + "IdentitiesOnly": "yes", + } + local_connection_config.include.assert_called_once() + api_client.runs.get.assert_called_once_with("main", "dev-run") + + def test_refreshes_job_id_on_every_resolution( + self, + local_connection_config: SimpleNamespace, + ) -> None: + stale_model = _run_model(job_id=uuid4()) + current_job_id = uuid4() + current_model = _run_model(job_id=current_job_id) + api_client = _api_client(current_model) + run = Run(api_client=api_client, project="main", run=stale_model) + + connection = run.get_direct_connection() + + assert connection.upstream_id == current_job_id.hex + assert connection.ssh_command == ( + "ssh", + f"{current_job_id.hex}@{connection.ssh_alias}", + ) + + def test_defaults_a_missing_proxy_port_to_22( + self, + local_connection_config: SimpleNamespace, + ) -> None: + job_id = uuid4() + run_model = _run_model(job_id=job_id, port=None) + run = Run(api_client=_api_client(run_model), project="main", run=run_model) + + connection = run.get_direct_connection() + + assert connection.sshproxy_port == 22 + assert connection.ssh_command == ("ssh", f"{job_id.hex}@{connection.ssh_alias}") + assert local_connection_config.update.call_args.args[2]["Port"] == 22 + + def test_expected_sshproxy_is_checked_before_local_state_changes( + self, + local_connection_config: SimpleNamespace, + ) -> None: + run_model = _run_model(job_id=uuid4()) + run = Run(api_client=_api_client(run_model), project="main", run=run_model) + + with pytest.raises(ClientError, match="expected endpoint"): + run.get_direct_connection( + expected_sshproxy_hostname="other.example.com", + expected_sshproxy_port=2222, + ) + + _assert_no_local_connection_state(local_connection_config) + + def test_rejects_non_owner_before_local_state_changes( + self, + local_connection_config: SimpleNamespace, + ) -> None: + run_model = _run_model(job_id=uuid4(), owner="another-user") + run = Run(api_client=_api_client(run_model), project="main", run=run_model) + + with pytest.raises(ClientError, match="run owner"): + run.get_direct_connection() + + _assert_no_local_connection_state(local_connection_config) + + @pytest.mark.parametrize( + ("status", "configuration_type", "message"), + [ + (RunStatus.PENDING, "dev-environment", "requires a running run"), + (RunStatus.RUNNING, "task", "only supported for dev environments"), + ], + ) + def test_requires_running_dev_environment( + self, + status: RunStatus, + configuration_type: str, + message: str, + local_connection_config: SimpleNamespace, + ) -> None: + run_model = _run_model( + job_id=uuid4(), + status=status, + configuration_type=configuration_type, + ) + run = Run(api_client=_api_client(run_model), project="main", run=run_model) + + with pytest.raises(ClientError, match=message): + run.get_direct_connection() + + _assert_no_local_connection_state(local_connection_config) + + def test_rejects_explicit_replica_without_a_running_job( + self, + local_connection_config: SimpleNamespace, + ) -> None: + run_model = _run_model(job_id=uuid4(), job_status=JobStatus.TERMINATED) + run = Run(api_client=_api_client(run_model), project="main", run=run_model) + + with pytest.raises(ClientError, match="Failed to find running replica"): + run.get_direct_connection(replica_num=0) + + _assert_no_local_connection_state(local_connection_config) + + def test_requires_server_proxy_connection_info( + self, + local_connection_config: SimpleNamespace, + ) -> None: + run_model = _run_model(job_id=uuid4()) + run_model.jobs[0].job_connection_info = None + run = Run(api_client=_api_client(run_model), project="main", run=run_model) + + with pytest.raises(ClientError, match="did not provide SSH proxy"): + run.get_direct_connection() + + _assert_no_local_connection_state(local_connection_config) + + @pytest.mark.parametrize( + "mutate", + [ + lambda jci: setattr(jci, "sshproxy_hostname", "-oProxyCommand=bad"), + lambda jci: setattr(jci, "sshproxy_hostname", "fe80::1%h"), + lambda jci: setattr(jci, "sshproxy_port", True), + lambda jci: setattr(jci, "sshproxy_port", 70000), + lambda jci: setattr(jci, "sshproxy_upstream_id", "bad@upstream"), + lambda jci: setattr(jci, "proxied_ssh_command", ["ssh", "other.example.com"]), + lambda jci: setattr(jci, "ide_name", "Other IDE"), + lambda jci: setattr(jci, "proxied_ide_url", "zed://ssh/untrusted/workspace"), + ], + ) + def test_rejects_inconsistent_or_unsafe_proxy_target( + self, + mutate, + local_connection_config: SimpleNamespace, + ) -> None: + run_model = _run_model(job_id=uuid4()) + mutate(run_model.jobs[0].job_connection_info) + run = Run(api_client=_api_client(run_model), project="main", run=run_model) + + with pytest.raises(ClientError): + run.get_direct_connection() + + _assert_no_local_connection_state(local_connection_config) + + @pytest.mark.parametrize( + "working_dir", + ["workspace/project", "/workspace/project\n-oProxyCommand=bad"], + ) + def test_rejects_an_unsafe_working_directory( + self, + working_dir: str, + local_connection_config: SimpleNamespace, + ) -> None: + run_model = _run_model(job_id=uuid4(), working_dir=working_dir) + run = Run(api_client=_api_client(run_model), project="main", run=run_model) + + with pytest.raises(ClientError, match="invalid dev environment working directory"): + run.get_direct_connection() + + _assert_no_local_connection_state(local_connection_config) + + @pytest.mark.parametrize( + ("ide", "ide_name", "executable"), + [ + ("vscode", "VS Code", "code"), + ("cursor", "Cursor", "cursor"), + ("windsurf", "Windsurf", "windsurf"), + ], + ) + def test_returns_validated_vscode_family_command( + self, + ide: str, + ide_name: str, + executable: str, + local_connection_config: SimpleNamespace, + ) -> None: + job_id = uuid4() + run_model = _run_model(job_id=job_id, ide=ide) + run = Run(api_client=_api_client(run_model), project="main", run=run_model) + + connection = run.get_direct_connection() + + assert connection.ide == ide + assert connection.ide_name == ide_name + assert connection.ide_command == ( + executable, + "--folder-uri", + f"vscode-remote://ssh-remote+{job_id.hex}@{connection.ssh_alias}/workspace/project", + ) + + def test_does_not_offer_an_ide_for_an_ssh_only_configuration( + self, + local_connection_config: SimpleNamespace, + ) -> None: + run_model = _run_model(job_id=uuid4(), ide=None) + run = Run(api_client=_api_client(run_model), project="main", run=run_model) + + connection = run.get_direct_connection() + + assert connection.ide is None + assert connection.ide_name is None + assert connection.ide_command is None + + def test_rejects_unexpected_ide_info_for_an_ssh_only_configuration( + self, + local_connection_config: SimpleNamespace, + ) -> None: + run_model = _run_model(job_id=uuid4(), ide=None) + run_model.jobs[0].job_connection_info.ide_name = "Zed" + run_model.jobs[0].job_connection_info.proxied_ide_url = "zed://ssh/unexpected/workspace" + run = Run(api_client=_api_client(run_model), project="main", run=run_model) + + with pytest.raises(ClientError, match="unexpected IDE connection information"): + run.get_direct_connection() + + _assert_no_local_connection_state(local_connection_config) + + def test_uses_distinct_aliases_for_concurrent_runs_in_one_project( + self, + local_connection_config: SimpleNamespace, + ) -> None: + first_model = _run_model(job_id=uuid4(), run_name="first-run") + second_model = _run_model(job_id=uuid4(), run_name="second-run") + first = Run(api_client=_api_client(first_model), project="main", run=first_model) + second = Run(api_client=_api_client(second_model), project="main", run=second_model) + + first_connection = first.get_direct_connection() + second_connection = second.get_direct_connection() + + assert first_connection.ssh_alias != second_connection.ssh_alias + + def test_accepts_an_extensible_safe_upstream_id( + self, + local_connection_config: SimpleNamespace, + ) -> None: + upstream_id = "runner.v2_job-01" + run_model = _run_model(job_id=uuid4(), upstream_id=upstream_id) + run = Run(api_client=_api_client(run_model), project="main", run=run_model) + + connection = run.get_direct_connection() + + assert connection.upstream_id == upstream_id + assert connection.ssh_command == ("ssh", f"{upstream_id}@{connection.ssh_alias}") + + @pytest.mark.parametrize("upstream_id", ["-leading-dash", "bad@id", "a" * 129]) + def test_rejects_an_unsafe_upstream_id( + self, + upstream_id: str, + local_connection_config: SimpleNamespace, + ) -> None: + run_model = _run_model(job_id=uuid4(), upstream_id=upstream_id) + run = Run(api_client=_api_client(run_model), project="main", run=run_model) + + with pytest.raises(ClientError, match="invalid SSH proxy upstream ID"): + run.get_direct_connection() + + _assert_no_local_connection_state(local_connection_config) + + def test_percent_encodes_the_zed_remote_path( + self, + local_connection_config: SimpleNamespace, + ) -> None: + job_id = uuid4() + run_model = _run_model( + job_id=job_id, + working_dir="/workspace/a project/#literal%value", + ) + run = Run(api_client=_api_client(run_model), project="main", run=run_model) + + connection = run.get_direct_connection() + + assert connection.ide_command == ( + "zed", + f"ssh://{job_id.hex}@{connection.ssh_alias}/workspace/a%20project/%23literal%25value", + ) + + def test_percent_encodes_a_vscode_family_remote_path( + self, + local_connection_config: SimpleNamespace, + ) -> None: + job_id = uuid4() + run_model = _run_model( + job_id=job_id, + ide="cursor", + working_dir="/workspace/a project/#literal%value", + ) + run = Run(api_client=_api_client(run_model), project="main", run=run_model) + + connection = run.get_direct_connection() + + assert connection.ide_command == ( + "cursor", + "--folder-uri", + f"vscode-remote://ssh-remote+{job_id.hex}@{connection.ssh_alias}" + "/workspace/a%20project/%23literal%25value", + )