Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions frontend/src/pages/Runs/CreateDevEnvironment/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,28 @@ export const FORM_FIELD_NAMES = {
repo_path: 'repo_path',
working_dir: 'working_dir',
} as const satisfies Record<IRunEnvironmentFormKeys, IRunEnvironmentFormKeys>;

export const IDE_OPTIONS = [
{
label: 'Cursor',
value: 'cursor',
},
{
label: 'VS Code',
value: 'vscode',
},
{
label: 'Windsurf',
value: 'windsurf',
},
] as const;

export const IDE_DISPLAY_NAMES: Record<string, string> = {
cursor: 'Cursor',
vscode: 'VS Code',
windsurf: 'Windsurf',
};

export const getIDEDisplayName = (ide: string): string => {
return IDE_DISPLAY_NAMES[ide] || 'IDE';
};
15 changes: 2 additions & 13 deletions frontend/src/pages/Runs/CreateDevEnvironment/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { NoFleetProjectAlert } from 'pages/Project/components/NoFleetProjectAler

import { useGenerateYaml } from './hooks/useGenerateYaml';
import { useGetRunSpecFromYaml } from './hooks/useGetRunSpecFromYaml';
import { FORM_FIELD_NAMES } from './constants';
import { FORM_FIELD_NAMES, IDE_OPTIONS } from './constants';

import { IRunEnvironmentFormKeys, IRunEnvironmentFormValues } from './types';

Expand All @@ -32,17 +32,6 @@ const namesFieldError = 'Only latin characters, dashes, and digits';
const urlFormatError = 'Only URLs';
const workingDirFormatError = 'Must be an absolute path';

const ideOptions = [
{
label: 'Cursor',
value: 'cursor',
},
{
label: 'VS Code',
value: 'vscode',
},
];

enum DockerPythonTabs {
DOCKER = 'docker',
PYTHON = 'python',
Expand Down Expand Up @@ -348,7 +337,7 @@ export const CreateDevEnvironment: React.FC = () => {
description={t('runs.dev_env.wizard.ide_description')}
control={control}
name="ide"
options={ideOptions}
options={IDE_OPTIONS}
disabled={loading}
/>

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/Runs/CreateDevEnvironment/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export interface IRunEnvironmentFormValues {
offer: IGpu;
name: string;
ide: 'cursor' | 'vscode';
ide: 'cursor' | 'vscode' | 'windsurf';
config_yaml: string;
docker: boolean;
image?: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import { copyToClipboard } from 'libs';

import { useConfigProjectCliCommand } from 'pages/Project/hooks/useConfigProjectCliComand';
import { getIDEDisplayName } from 'pages/Runs/CreateDevEnvironment/constants';

import styles from './styles.module.scss';

Expand Down Expand Up @@ -52,7 +53,9 @@ export const ConnectToRunWithDevEnvConfiguration: FC<{ run: IRun }> = ({ run })
const [attachCommand, copyAttachCommand] = getAttachCommand(run);
const [sshCommand, copySSHCommand] = getSSHCommand(run);

const openInIDEUrl = `${run.run_spec.configuration.ide}://vscode-remote/ssh-remote+${run.run_spec.run_name}/${run.run_spec.working_dir || 'workflow'}`;
const configuration = run.run_spec.configuration as TDevEnvironmentConfiguration;
const openInIDEUrl = `${configuration.ide}://vscode-remote/ssh-remote+${run.run_spec.run_name}/${run.run_spec.working_dir || 'workflow'}`;
const ideDisplayName = getIDEDisplayName(configuration.ide);

const [configCliCommand, copyCliCommand] = useConfigProjectCliCommand({ projectName: run.project_name });

Expand All @@ -74,7 +77,7 @@ export const ConnectToRunWithDevEnvConfiguration: FC<{ run: IRun }> = ({ run })
onNavigate={({ detail }) => setActiveStepIndex(detail.requestedStepIndex)}
activeStepIndex={activeStepIndex}
onSubmit={() => window.open(openInIDEUrl, '_blank')}
submitButtonText="Open in VS Code"
submitButtonText={`Open in ${ideDisplayName}`}
allowSkipTo
steps={[
{
Expand Down Expand Up @@ -216,15 +219,15 @@ export const ConnectToRunWithDevEnvConfiguration: FC<{ run: IRun }> = ({ run })
},
{
title: 'Open',
description: 'After the CLI is attached, you can open the dev environment in VS Code.',
description: `After the CLI is attached, you can open the dev environment in ${ideDisplayName}.`,
content: (
<SpaceBetween size="s">
<Button
variant="primary"
external={true}
onClick={() => window.open(openInIDEUrl, '_blank')}
>
Open in VS Code
Open in {ideDisplayName}
</Button>

<ExpandableSection headerText="Need plain SSH?">
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/types/run.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ declare type TGPUResources = IGPUSpecRequest & {
name?: string | string[];
};

declare type TIde = 'cursor' | 'vscode';
declare type TIde = 'cursor' | 'vscode' | 'windsurf';

declare type TVolumeMountPointRequest = {
name: string | string[];
Expand Down
58 changes: 58 additions & 0 deletions src/dstack/_internal/cli/services/configurators/run.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import argparse
import json
import os
import shlex
import shutil
import subprocess
import sys
import time
Expand Down Expand Up @@ -677,6 +680,14 @@ def apply_args(self, conf: DevEnvironmentConfiguration, args: argparse.Namespace
"Fix by opening [code]Command Palette[/code], executing [code]Shell Command: "
"Install 'cursor' command in PATH[/code], and restarting terminal.[/]\n"
)
if conf.ide == "windsurf" and conf.version is None:
conf.version = _detect_windsurf_version()
if conf.version is None:
console.print(
"[secondary]Unable to detect the Windsurf version and pre-install extensions. "
"Fix by opening [code]Command Palette[/code], executing [code]Shell Command: "
"Install 'surf' command in PATH[/code], and restarting terminal.[/]\n"
)
Comment on lines +686 to +690
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't find it in the Command Palette. Am I doing something wrong?

Image

Also can't find it in VSCode

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot 2026-01-07 at 09 49 15

Windsurf Version: 1.13.5

Same in VSCode (Version: 1.106.3):

Screenshot 2026-01-07 at 09 50 15



class ServiceConfigurator(RunWithCommandsConfiguratorMixin, BaseRunConfigurator):
Expand Down Expand Up @@ -730,6 +741,53 @@ def _detect_cursor_version(exe: str = "cursor") -> Optional[str]:
return None


def _detect_windsurf_version(exe: str = "windsurf") -> Optional[str]:
"""
Detects the installed Windsurf product version and commit hash.
Returns string in format 'version@commit' (e.g., '1.13.5@97d7a...') or None.
"""
# 1. Locate executable in PATH
cmd_path = shutil.which(exe)
if not cmd_path:
return None

try:
# 2. Resolve symlinks to find the actual installation directory
current_dir = os.path.dirname(os.path.realpath(cmd_path))

# 3. Walk up directory tree to find 'resources/app/product.json'
# Covers Linux (/opt/...), macOS (Contents/Resources/...), and Windows
for _ in range(6):
# Check standard lowercase and macOS TitleCase
for resource_folder in ["resources", "Resources"]:
json_path = os.path.join(current_dir, resource_folder, "app", "product.json")

if os.path.exists(json_path):
try:
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
# Key 'windsurfVersion' is the product version (1.13.5)
# Key 'version' is the base VS Code version (1.9x)
ver = data.get("windsurfVersion")
commit = data.get("commit")

if ver and commit:
return f"{ver}@{commit}"
except (OSError, json.JSONDecodeError):
continue

# Move up one directory level
parent = os.path.dirname(current_dir)
if parent == current_dir: # Reached filesystem root
break
current_dir = parent

except Exception:
return None

return None


def _print_service_urls(run: Run) -> None:
if run._run.run_spec.configuration.type != RunConfigurationType.SERVICE.value:
return
Expand Down
26 changes: 23 additions & 3 deletions src/dstack/_internal/core/models/configurations.py
Original file line number Diff line number Diff line change
Expand Up @@ -619,10 +619,17 @@ def check_image_or_commands_present(cls, values):

class DevEnvironmentConfigurationParams(CoreModel):
ide: Annotated[
Union[Literal["vscode"], Literal["cursor"]],
Field(description="The IDE to run. Supported values include `vscode` and `cursor`"),
Union[Literal["vscode"], Literal["cursor"], Literal["windsurf"]],
Field(
description="The IDE to run. Supported values include `vscode`, `cursor`, and `windsurf`"
),
]
version: Annotated[Optional[str], Field(description="The version of the IDE")] = None
version: Annotated[
Optional[str],
Field(
description="The version of the IDE. For `windsurf`, the version is in the format `version@commit`"
),
] = None
init: Annotated[CommandsList, Field(description="The shell commands to run on startup")] = []
inactivity_duration: Annotated[
Optional[Union[Literal["off"], int, bool, str]],
Expand All @@ -649,6 +656,19 @@ def parse_inactivity_duration(
return v
return None

@root_validator
def validate_windsurf_version_format(cls, values):
ide = values.get("ide")
version = values.get("version")
if ide == "windsurf" and version:
# Validate format: version@commit
if not re.match(r"^.+@[a-f0-9]+$", version):
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regex allows multiple @ but split assumes exactly one

The regex validation ^.+@[a-f0-9]+$ allows versions containing multiple @ symbols (e.g., 1.0@@abc123 or 1.0@beta@abc123) because .+ is greedy and matches characters including @. However, windsurf.py uses version, commit = self.version.split("@") which expects exactly two parts. A malformed version passing validation would cause a runtime ValueError: too many values to unpack. The regex could use ^[^@]+@[a-f0-9]+$ to explicitly exclude @ from the version portion.

Additional Locations (1)

Fix in Cursor Fix in Web

raise ValueError(
f"Invalid Windsurf version format: `{version}`. "
"Expected format: `version@commit` (e.g., `1.106.0@8951cd3ad688e789573d7f51750d67ae4a0bea7d`)"
)
return values


class DevEnvironmentConfigurationConfig(
ProfileParamsConfig,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from dstack._internal.server.services.jobs.configurators.base import JobConfigurator
from dstack._internal.server.services.jobs.configurators.extensions.cursor import CursorDesktop
from dstack._internal.server.services.jobs.configurators.extensions.vscode import VSCodeDesktop
from dstack._internal.server.services.jobs.configurators.extensions.windsurf import WindsurfDesktop

INSTALL_IPYKERNEL = (
"(echo 'pip install ipykernel...' && pip install -q --no-cache-dir ipykernel 2> /dev/null) || "
Expand All @@ -24,6 +25,8 @@ def __init__(self, run_spec: RunSpec, secrets: Dict[str, str]):
__class = VSCodeDesktop
elif run_spec.configuration.ide == "cursor":
__class = CursorDesktop
elif run_spec.configuration.ide == "windsurf":
__class = WindsurfDesktop
else:
raise ServerClientError(f"Unsupported IDE: {run_spec.configuration.ide}")
self.ide = __class(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from typing import List, Optional


class WindsurfDesktop:
def __init__(
self,
run_name: Optional[str],
version: Optional[str],
extensions: List[str],
):
self.run_name = run_name
self.version = version
self.extensions = extensions

def get_install_commands(self) -> List[str]:
commands = []
if self.version is not None:
version, commit = self.version.split("@")
url = f"https://windsurf-stable.codeiumdata.com/linux-reh-$arch/stable/{commit}/windsurf-reh-linux-$arch-{version}.tar.gz"
archive = "windsurf-reh-linux-$arch.tar.gz"
target = f'~/.windsurf-server/bin/"{commit}"'
commands.extend(
[
'if [ $(uname -m) = "aarch64" ]; then arch="arm64"; else arch="x64"; fi',
"mkdir -p /tmp",
f'wget -q --show-progress "{url}" -O "/tmp/{archive}"',
f"mkdir -vp {target}",
f'tar --no-same-owner -xz --strip-components=1 -C {target} -f "/tmp/{archive}"',
f'rm "/tmp/{archive}"',
]
)
if self.extensions:
extensions = " ".join(f'--install-extension "{name}"' for name in self.extensions)
commands.append(f'PATH="$PATH":{target}/bin windsurf-server {extensions}')
return commands

def get_print_readme_commands(self) -> List[str]:
return [
"echo To open in Windsurf, use link below:",
"echo",
f'echo " windsurf://vscode-remote/ssh-remote+{self.run_name}$DSTACK_WORKING_DIR"',
"echo",
]
52 changes: 51 additions & 1 deletion src/tests/_internal/core/models/test_configurations.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@

from dstack._internal.core.errors import ConfigurationError
from dstack._internal.core.models.common import RegistryAuth
from dstack._internal.core.models.configurations import RepoSpec, parse_run_configuration
from dstack._internal.core.models.configurations import (
DevEnvironmentConfigurationParams,
RepoSpec,
parse_run_configuration,
)
from dstack._internal.core.models.resources import Range


Expand Down Expand Up @@ -139,3 +143,49 @@ def test_registry_auth_hashable():
"""
registry_auth = RegistryAuth(username="username", password="password")
hash(registry_auth)


class TestDevEnvironmentConfigurationParams:
def test_windsurf_version_valid_format(self):
params = DevEnvironmentConfigurationParams(
ide="windsurf", version="1.106.0@8951cd3ad688e789573d7f51750d67ae4a0bea7d"
)
assert params.ide == "windsurf"
assert params.version == "1.106.0@8951cd3ad688e789573d7f51750d67ae4a0bea7d"

def test_windsurf_version_valid_short_commit(self):
params = DevEnvironmentConfigurationParams(ide="windsurf", version="1.0.0@abc123")
assert params.version == "1.0.0@abc123"

def test_windsurf_version_empty_allowed(self):
params = DevEnvironmentConfigurationParams(ide="windsurf", version=None)
assert params.ide == "windsurf"
assert params.version is None

def test_windsurf_version_invalid_missing_at(self):
with pytest.raises(ValueError, match="Invalid Windsurf version format"):
DevEnvironmentConfigurationParams(ide="windsurf", version="1.106.0")

def test_windsurf_version_invalid_missing_commit(self):
with pytest.raises(ValueError, match="Invalid Windsurf version format"):
DevEnvironmentConfigurationParams(ide="windsurf", version="1.106.0@")

def test_windsurf_version_invalid_missing_version(self):
with pytest.raises(ValueError, match="Invalid Windsurf version format"):
DevEnvironmentConfigurationParams(
ide="windsurf", version="@8951cd3ad688e789573d7f51750d67ae4a0bea7d"
)

def test_windsurf_version_invalid_non_hex_commit(self):
with pytest.raises(ValueError, match="Invalid Windsurf version format"):
DevEnvironmentConfigurationParams(ide="windsurf", version="1.106.0@ghijklmnop")

def test_vscode_version_not_validated(self):
params = DevEnvironmentConfigurationParams(ide="vscode", version="1.80.0")
assert params.ide == "vscode"
assert params.version == "1.80.0"

def test_cursor_version_not_validated(self):
params = DevEnvironmentConfigurationParams(ide="cursor", version="0.40.0")
assert params.ide == "cursor"
assert params.version == "0.40.0"