diff --git a/.golangci.yaml b/.golangci.yaml
index 94eed5599..efaaa2f76 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -37,6 +37,11 @@ linters:
- gosec
text: "G101"
path: "pkg/telemetry/analytics/client.go"
+ # Per-IDE async commands and setup functions are intentionally parallel
+ # (one package per browser IDE), so dupl across them is by design.
+ - linters:
+ - dupl
+ path: "cmd/internal/agentcontainer/(codeserver|vscodeweb|openvscode)_async.go"
- path: "hack/"
linters:
- forbidigo
diff --git a/SECURITY.md b/SECURITY.md
index 99580e1ab..bbf25f5a7 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -8,4 +8,4 @@ See the [install instructions for Devsy](https://devsy.sh/docs/getting-started/i
## Reporting a Vulnerability
-Please report vulnerabilities to: [security@devsy.sh](mailto:security@devsy.sh)
+Please report vulnerabilities to: [dev@devsy.sh](mailto:dev@devsy.sh)
diff --git a/cmd/internal/agentcontainer/container.go b/cmd/internal/agentcontainer/container.go
index d750ef01f..d93f8a320 100644
--- a/cmd/internal/agentcontainer/container.go
+++ b/cmd/internal/agentcontainer/container.go
@@ -25,6 +25,7 @@ func NewContainerCmd(flags *flags.GlobalFlags) *cobra.Command {
containerCmd.AddCommand(NewVSCodeAsyncCmd())
containerCmd.AddCommand(NewOpenVSCodeAsyncCmd())
containerCmd.AddCommand(NewCodeServerAsyncCmd())
+ containerCmd.AddCommand(NewVSCodeWebAsyncCmd())
containerCmd.AddCommand(NewCredentialsServerCmd(flags))
containerCmd.AddCommand(NewSetupDevsyPlatformAccessCmd(flags))
containerCmd.AddCommand(NewSSHServerCmd(flags))
diff --git a/cmd/internal/agentcontainer/setup.go b/cmd/internal/agentcontainer/setup.go
index d54e676aa..2bb6a36b0 100644
--- a/cmd/internal/agentcontainer/setup.go
+++ b/cmd/internal/agentcontainer/setup.go
@@ -39,6 +39,7 @@ import (
"github.com/devsy-org/devsy/pkg/ide/openvscode"
"github.com/devsy-org/devsy/pkg/ide/rstudio"
"github.com/devsy-org/devsy/pkg/ide/vscode"
+ "github.com/devsy-org/devsy/pkg/ide/vscodeweb"
"github.com/devsy-org/devsy/pkg/log"
provider2 "github.com/devsy-org/devsy/pkg/provider"
"github.com/devsy-org/devsy/pkg/ts"
@@ -595,6 +596,8 @@ func (cmd *SetupContainerCmd) installIDE(
return cmd.setupOpenVSCode(setupInfo, ide.Options)
case string(config2.IDECodeServer):
return cmd.setupCodeServer(setupInfo, ide.Options)
+ case string(config2.IDEVSCodeWeb):
+ return cmd.setupVSCodeWeb(setupInfo, ide.Options)
case string(config2.IDEGoland):
return jetbrains.NewGolandServer(config.GetRemoteUser(setupInfo), ide.Options).
Install(setupInfo)
@@ -773,6 +776,7 @@ func (cmd *SetupContainerCmd) setupOpenVSCode(
return openVSCode.Start()
}
+//nolint:dupl // mirrored by setupVSCodeWeb by design (one package per browser IDE).
func (cmd *SetupContainerCmd) setupCodeServer(
setupInfo *config.Result,
ideOptions map[string]config2.OptionValue,
@@ -831,6 +835,68 @@ func (cmd *SetupContainerCmd) setupCodeServer(
return cs.Start()
}
+// setupVSCodeWeb intentionally mirrors setupCodeServer; the per-IDE browser
+// setup functions are parallel by design (one package per browser IDE).
+//
+//nolint:dupl // mirrors setupCodeServer by design.
+func (cmd *SetupContainerCmd) setupVSCodeWeb(
+ setupInfo *config.Result,
+ ideOptions map[string]config2.OptionValue,
+) error {
+ log.Debugf("setup vscode-web")
+ vsCodeConfiguration := config.GetVSCodeConfiguration(setupInfo.MergedConfig)
+ settings := ""
+ if len(vsCodeConfiguration.Settings) > 0 {
+ out, err := json.Marshal(vsCodeConfiguration.Settings)
+ if err != nil {
+ return err
+ }
+ settings = string(out)
+ }
+
+ user := config.GetRemoteUser(setupInfo)
+ vw := vscodeweb.NewVSCodeWeb(vscodeweb.ServerOptions{
+ Extensions: vsCodeConfiguration.Extensions,
+ Settings: settings,
+ UserName: user,
+ Host: "0.0.0.0",
+ Port: strconv.Itoa(vscodeweb.DefaultVSCodeWebPort),
+ Values: ideOptions,
+ })
+
+ if err := vw.Install(); err != nil {
+ return err
+ }
+
+ if len(vsCodeConfiguration.Extensions) > 0 {
+ err := command.StartBackgroundOnce("vscode-web-async", func() (*exec.Cmd, error) {
+ log.Infof(
+ "installing extensions in the background: %s",
+ strings.Join(vsCodeConfiguration.Extensions, ","),
+ )
+ binaryPath, err := os.Executable()
+ if err != nil {
+ return nil, err
+ }
+ //nolint:gosec // binaryPath is from os.Executable(), not user input
+ return exec.Command(
+ binaryPath,
+ cmdInternal,
+ cmdAgent,
+ cmdContainer,
+ "vscode-web-async",
+ "--setup-info",
+ cmd.SetupInfo,
+ ), nil
+ })
+ if err != nil {
+ return fmt.Errorf("install extensions: %w", err)
+ }
+ }
+
+ return vw.Start()
+}
+
func configureSystemGitCredentials(
ctx context.Context,
client tunnel.TunnelClient,
diff --git a/cmd/internal/agentcontainer/vscodeweb_async.go b/cmd/internal/agentcontainer/vscodeweb_async.go
new file mode 100644
index 000000000..509ffd2f6
--- /dev/null
+++ b/cmd/internal/agentcontainer/vscodeweb_async.go
@@ -0,0 +1,59 @@
+package agentcontainer
+
+import (
+ "encoding/json"
+
+ "github.com/devsy-org/devsy/cmd/flags"
+ "github.com/devsy-org/devsy/pkg/compress"
+ "github.com/devsy-org/devsy/pkg/devcontainer/config"
+ "github.com/devsy-org/devsy/pkg/ide/vscodeweb"
+ "github.com/devsy-org/devsy/pkg/log"
+ "github.com/spf13/cobra"
+)
+
+// VSCodeWebAsyncCmd holds the cmd flags.
+type VSCodeWebAsyncCmd struct {
+ *flags.GlobalFlags
+
+ SetupInfo string
+}
+
+// NewVSCodeWebAsyncCmd creates a new command.
+func NewVSCodeWebAsyncCmd() *cobra.Command {
+ cmd := &VSCodeWebAsyncCmd{}
+ vsCodeWebAsyncCmd := &cobra.Command{
+ Use: "vscode-web-async",
+ Short: "Starts VS Code Web",
+ Args: cobra.NoArgs,
+ RunE: cmd.Run,
+ }
+ vsCodeWebAsyncCmd.Flags().
+ StringVar(&cmd.SetupInfo, "setup-info", "", "The container setup info")
+ _ = vsCodeWebAsyncCmd.MarkFlagRequired("setup-info")
+ return vsCodeWebAsyncCmd
+}
+
+// Run runs the command logic.
+func (cmd *VSCodeWebAsyncCmd) Run(_ *cobra.Command, _ []string) error {
+ log.Debugf("Start setting up container")
+ decompressed, err := compress.Decompress(cmd.SetupInfo)
+ if err != nil {
+ return err
+ }
+
+ setupInfo := &config.Result{}
+ if err := json.Unmarshal([]byte(decompressed), setupInfo); err != nil {
+ return err
+ }
+
+ return setupVSCodeWebExtensions(setupInfo)
+}
+
+func setupVSCodeWebExtensions(setupInfo *config.Result) error {
+ vsCodeConfiguration := config.GetVSCodeConfiguration(setupInfo.MergedConfig)
+ user := config.GetRemoteUser(setupInfo)
+ return vscodeweb.NewVSCodeWeb(vscodeweb.ServerOptions{
+ Extensions: vsCodeConfiguration.Extensions,
+ UserName: user,
+ }).InstallExtensions()
+}
diff --git a/cmd/pro/start.go b/cmd/pro/start.go
index a928d015b..69961e3c7 100644
--- a/cmd/pro/start.go
+++ b/cmd/pro/start.go
@@ -327,7 +327,7 @@ func (cmd *StartCmd) retryUpgradeAfterPurge(
"- via Online Chat: %s\n- via Email: %s\n",
greenBold("https://slack.devsy.sh/"),
greenBold("https://devsy.sh/"),
- greenBold("support@devsy.sh"),
+ greenBold("dev@devsy.sh"),
),
)
}
diff --git a/desktop/package.json b/desktop/package.json
index 547860d69..f379c8440 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -5,7 +5,7 @@
"homepage": "https://devsy.sh",
"author": {
"name": "Devsy",
- "email": "support@devsy.sh"
+ "email": "dev@devsy.sh"
},
"type": "module",
"main": "dist/main/index.js",
diff --git a/desktop/src/renderer/public/icons/ides/vscode-web.svg b/desktop/src/renderer/public/icons/ides/vscode-web.svg
new file mode 100644
index 000000000..a9f83279a
--- /dev/null
+++ b/desktop/src/renderer/public/icons/ides/vscode-web.svg
@@ -0,0 +1,37 @@
+
diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte
index 551353664..fcc4d33ed 100644
--- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte
+++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte
@@ -82,6 +82,7 @@ const IDE_GROUPS = [
{ value: "none", label: "None", iconName: "none" },
{ value: "vscode", label: "VS Code", iconName: "vscode" },
{ value: "openvscode", label: "VS Code Browser", iconName: "vscodebrowser" },
+ { value: "vscode-web", label: "VS Code Web", iconName: "vscode-web" },
{ value: "code-server", label: "code-server", iconName: "code-server" },
{ value: "cursor", label: "Cursor", iconName: "cursor" },
{ value: "zed", label: "Zed", iconName: "zed" },
diff --git a/desktop/src/renderer/src/pages/SettingsPage.svelte b/desktop/src/renderer/src/pages/SettingsPage.svelte
index 1e2550eb6..d111828ca 100644
--- a/desktop/src/renderer/src/pages/SettingsPage.svelte
+++ b/desktop/src/renderer/src/pages/SettingsPage.svelte
@@ -76,6 +76,7 @@ const IDE_OPTIONS = [
{ value: "none", label: "None" },
{ value: "vscode", label: "VS Code" },
{ value: "openvscode", label: "VS Code Browser" },
+ { value: "vscode-web", label: "VS Code Web" },
{ value: "cursor", label: "Cursor" },
{ value: "zed", label: "Zed" },
{ value: "codium", label: "VSCodium" },
diff --git a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte
index 0ff0df153..347dfc62d 100644
--- a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte
+++ b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte
@@ -62,6 +62,7 @@ const IDE_OPTIONS = [
{ value: "none", label: "None" },
{ value: "vscode", label: "VS Code" },
{ value: "openvscode", label: "VS Code Browser" },
+ { value: "vscode-web", label: "VS Code Web" },
{ value: "code-server", label: "code-server" },
{ value: "cursor", label: "Cursor" },
{ value: "zed", label: "Zed" },
diff --git a/e2e/tests/ide/ide.go b/e2e/tests/ide/ide.go
index e54566400..81059b4de 100644
--- a/e2e/tests/ide/ide.go
+++ b/e2e/tests/ide/ide.go
@@ -43,6 +43,9 @@ var _ = ginkgo.Describe("devsy ide test suite", ginkgo.Label("ide"), ginkgo.Orde
err = f.DevsyUpWithIDE(ctx, tempDir, "--ide-launch=skip", "--ide=jupyternotebook")
framework.ExpectNoError(err)
+ err = f.DevsyUpWithIDE(ctx, tempDir, "--ide-launch=skip", "--ide=vscode-web")
+ framework.ExpectNoError(err)
+
// TODO: Fix broken IDE
// err = f.DevsyUpWithIDE(ctx, tempDir, "--ide-launch=skip", "--ide=fleet")
// framework.ExpectNoError(err)
diff --git a/pkg/config/ide.go b/pkg/config/ide.go
index aa5009f12..3ebf9d541 100644
--- a/pkg/config/ide.go
+++ b/pkg/config/ide.go
@@ -8,6 +8,7 @@ const (
IDEVSCodeInsiders IDE = "vscode-insiders"
IDEOpenVSCode IDE = "openvscode"
IDECodeServer IDE = "code-server"
+ IDEVSCodeWeb IDE = "vscode-web"
IDEIntellij IDE = "intellij"
IDEGoland IDE = "goland"
IDERustRover IDE = "rustrover"
diff --git a/pkg/driver/kubernetes/pullsecrets.go b/pkg/driver/kubernetes/pullsecrets.go
index 8e239b6f8..4a9b20b98 100644
--- a/pkg/driver/kubernetes/pullsecrets.go
+++ b/pkg/driver/kubernetes/pullsecrets.go
@@ -119,7 +119,7 @@ func (k *KubernetesDriver) createPullSecret(
dockerCredentials *dockercredentials.Credentials,
) error {
authToken := dockerCredentials.AuthToken()
- email := "noreply@devsy.sh"
+ email := "dev@devsy.sh"
encodedSecretData, err := PreparePullSecretData(dockerCredentials.ServerURL, authToken, email)
if err != nil {
diff --git a/pkg/ide/ideparse/parse.go b/pkg/ide/ideparse/parse.go
index 329771f0e..f78c7c556 100644
--- a/pkg/ide/ideparse/parse.go
+++ b/pkg/ide/ideparse/parse.go
@@ -19,6 +19,7 @@ import (
"github.com/devsy-org/devsy/pkg/ide/openvscode"
"github.com/devsy-org/devsy/pkg/ide/rstudio"
"github.com/devsy-org/devsy/pkg/ide/vscode"
+ "github.com/devsy-org/devsy/pkg/ide/vscodeweb"
"github.com/devsy-org/devsy/pkg/provider"
)
@@ -67,6 +68,13 @@ var AllowedIDEs = []AllowedIDE{
Icon: "https://raw.githubusercontent.com/coder/code-server/main/src/browser/media/favicon.svg",
Group: config.IDEGroupPrimary,
},
+ {
+ Name: config.IDEVSCodeWeb,
+ DisplayName: "VS Code Web",
+ Options: vscodeweb.Options,
+ Icon: config.WebsiteAssetsURL + "/vscode-web.svg",
+ Group: config.IDEGroupPrimary,
+ },
{
Name: config.IDECursor,
DisplayName: "Cursor",
diff --git a/pkg/ide/opener/browser_tunnel_state.go b/pkg/ide/opener/browser_tunnel_state.go
index 7e4e13893..55c508634 100644
--- a/pkg/ide/opener/browser_tunnel_state.go
+++ b/pkg/ide/opener/browser_tunnel_state.go
@@ -34,6 +34,10 @@ const LabelVSCodeBrowser = "vscode"
// (coder.com) browser-IDE tunnel.
const LabelCodeServer = "code-server"
+// LabelVSCodeWeb is the TunnelState.Label value used for the official VS Code
+// (code serve-web) browser-IDE tunnel.
+const LabelVSCodeWeb = "vscode-web"
+
// TunnelState describes a running browser tunnel for a workspace.
//
// CreateTime is the helper process's creation timestamp in milliseconds
diff --git a/pkg/ide/opener/opener.go b/pkg/ide/opener/opener.go
index 45da2d112..4e8e7dd7a 100644
--- a/pkg/ide/opener/opener.go
+++ b/pkg/ide/opener/opener.go
@@ -5,6 +5,7 @@ import (
"context"
"fmt"
"net"
+ "net/url"
"strconv"
"strings"
@@ -22,6 +23,7 @@ import (
"github.com/devsy-org/devsy/pkg/ide/openvscode"
"github.com/devsy-org/devsy/pkg/ide/rstudio"
"github.com/devsy-org/devsy/pkg/ide/vscode"
+ "github.com/devsy-org/devsy/pkg/ide/vscodeweb"
"github.com/devsy-org/devsy/pkg/ide/zed"
pkglog "github.com/devsy-org/devsy/pkg/log"
open2 "github.com/devsy-org/devsy/pkg/open"
@@ -97,6 +99,8 @@ func browserIDEOpener(
return openVSCodeBrowser, true
case string(config.IDECodeServer):
return openCodeServerBrowser, true
+ case string(config.IDEVSCodeWeb):
+ return openVSCodeWebBrowser, true
case string(config.IDEJupyterNotebook):
return openJupyterBrowser, true
case string(config.IDEMarimo):
@@ -417,7 +421,7 @@ func openVSCodeBrowser(
Label: LabelVSCodeBrowser,
LogName: "vscode",
TargetURLFn: func(port int, folder string) string {
- return fmt.Sprintf("http://localhost:%d/?folder=%s", port, folder)
+ return fmt.Sprintf("http://localhost:%d/?folder=%s", port, url.QueryEscape(folder))
},
})
}
@@ -436,7 +440,26 @@ func openCodeServerBrowser(
Label: LabelCodeServer,
LogName: "code-server",
TargetURLFn: func(port int, folder string) string {
- return fmt.Sprintf("http://localhost:%d/?folder=%s", port, folder)
+ return fmt.Sprintf("http://localhost:%d/?folder=%s", port, url.QueryEscape(folder))
+ },
+ })
+}
+
+func openVSCodeWebBrowser(
+ ctx context.Context,
+ ideOptions map[string]config.OptionValue,
+ params IDEParams,
+) (string, error) {
+ return openBrowserIDE(ctx, params, browserIDESpec{
+ BindAddrOption: vscodeweb.Options.GetValue(ideOptions, vscodeweb.BindAddressOption),
+ DefaultPort: vscodeweb.DefaultVSCodeWebPort,
+ ForwardPorts: vscodeweb.Options.GetValue(
+ ideOptions, vscodeweb.ForwardPortsOption,
+ ) == config.BoolTrue,
+ Label: LabelVSCodeWeb,
+ LogName: "vscode-web",
+ TargetURLFn: func(port int, folder string) string {
+ return fmt.Sprintf("http://localhost:%d/?folder=%s", port, url.QueryEscape(folder))
},
})
}
diff --git a/pkg/ide/types.go b/pkg/ide/types.go
index 8d9bd478c..b3f7b1be2 100644
--- a/pkg/ide/types.go
+++ b/pkg/ide/types.go
@@ -75,6 +75,8 @@ func ReusesAuthSock(ide string) bool {
return true
case string(config.IDECodeServer):
return true
+ case string(config.IDEVSCodeWeb):
+ return true
case string(config.IDEJupyterNotebook):
return true
case string(config.IDEMarimo):
diff --git a/pkg/ide/vscodeweb/vscodeweb.go b/pkg/ide/vscodeweb/vscodeweb.go
new file mode 100644
index 000000000..5eb1a55fc
--- /dev/null
+++ b/pkg/ide/vscodeweb/vscodeweb.go
@@ -0,0 +1,385 @@
+package vscodeweb
+
+import (
+ "fmt"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strconv"
+
+ "github.com/devsy-org/devsy/pkg/command"
+ "github.com/devsy-org/devsy/pkg/config"
+ copy2 "github.com/devsy-org/devsy/pkg/copy"
+ "github.com/devsy-org/devsy/pkg/extract"
+ devsyhttp "github.com/devsy-org/devsy/pkg/http"
+ "github.com/devsy-org/devsy/pkg/ide"
+ "github.com/devsy-org/devsy/pkg/ide/vscode"
+ "github.com/devsy-org/devsy/pkg/log"
+ "github.com/devsy-org/devsy/pkg/util"
+)
+
+// Release URL format for the standalone VS Code CLI. The CLI tarball contains a
+// single `code` binary at its root; `code serve-web` fetches the actual server
+// payload at runtime on first connect (requires outbound network access).
+const (
+ downloadAmd64Template = "https://update.code.visualstudio.com/%s/cli-linux-x64/stable"
+ downloadArm64Template = "https://update.code.visualstudio.com/%s/cli-linux-arm64/stable"
+)
+
+const (
+ ForwardPortsOption = "FORWARD_PORTS"
+ BindAddressOption = "BIND_ADDRESS"
+ VersionOption = "VERSION"
+ DownloadAmd64Option = "DOWNLOAD_AMD64"
+ DownloadArm64Option = "DOWNLOAD_ARM64"
+)
+
+var Options = ide.Options{
+ ForwardPortsOption: {
+ Name: ForwardPortsOption,
+ Description: "If Devsy should automatically do port-forwarding",
+ Default: "true",
+ Enum: []string{"true", "false"},
+ },
+ BindAddressOption: {
+ Name: BindAddressOption,
+ Description: "The address to bind VS Code Web to locally, e.g. 0.0.0.0:12345",
+ Default: "",
+ },
+ VersionOption: {
+ Name: VersionOption,
+ Description: "The VS Code version for the serve-web CLI " +
+ "(a release version like 1.96.4, or 'latest')",
+ Default: "1.96.4",
+ },
+ DownloadArm64Option: {
+ Name: DownloadArm64Option,
+ Description: "The download url for the arm64 VS Code CLI",
+ },
+ DownloadAmd64Option: {
+ Name: DownloadAmd64Option,
+ Description: "The download url for the amd64 VS Code CLI",
+ },
+}
+
+const archArm64 = "arm64"
+
+// DefaultVSCodeWebPort sits next to openvscode (10800) and code-server (10801)
+// so a host running all three only collides under the FindAvailablePort
+// fallback path, not by default.
+const DefaultVSCodeWebPort = 10802
+
+// ServerOptions configures a VSCodeWeb instance.
+type ServerOptions struct {
+ Extensions []string
+ Settings string
+ UserName string
+ Host string
+ Port string
+ Values map[string]config.OptionValue
+}
+
+// VSCodeWeb installs the official VS Code CLI and runs it as `code serve-web`
+// inside the workspace, serving the browser IDE backed by the Microsoft
+// Marketplace.
+type VSCodeWeb struct {
+ values map[string]config.OptionValue
+ extensions []string
+ settings string
+ userName string
+ host string
+ port string
+}
+
+func NewVSCodeWeb(opts ServerOptions) *VSCodeWeb {
+ host := opts.Host
+ if host == "" {
+ host = "0.0.0.0"
+ }
+ port := opts.Port
+ if port == "" {
+ port = strconv.Itoa(DefaultVSCodeWebPort)
+ }
+ return &VSCodeWeb{
+ values: opts.Values,
+ extensions: opts.Extensions,
+ settings: opts.Settings,
+ userName: opts.UserName,
+ host: host,
+ port: port,
+ }
+}
+
+// Install downloads the VS Code CLI tarball, extracts the `code` binary under
+// /.vscode-web, and writes settings.json. The download is skipped only
+// when the existing install already matches the requested release (tracked via
+// a marker file), so changing VERSION / DOWNLOAD_* triggers a reinstall.
+// settings.json is rewritten on every call so workspace settings stay current.
+func (v *VSCodeWeb) Install() error {
+ location, err := prepareVSCodeWebLocation(v.userName)
+ if err != nil {
+ return err
+ }
+
+ releaseURL := v.getReleaseURL()
+ if !v.isInstalled(location, releaseURL) {
+ vscode.InstallAPKRequirements()
+
+ if err := downloadAndExtract(releaseURL, location); err != nil {
+ return err
+ }
+
+ if err := writeReleaseMarker(location, releaseURL); err != nil {
+ return fmt.Errorf("record release marker: %w", err)
+ }
+ }
+
+ if err := v.installSettings(); err != nil {
+ return fmt.Errorf("install settings: %w", err)
+ }
+
+ if v.userName != "" {
+ if err := copy2.ChownR(location, v.userName); err != nil {
+ return fmt.Errorf("chown: %w", err)
+ }
+ }
+
+ return nil
+}
+
+// InstallExtensions installs each requested extension via the VS Code CLI.
+// Partial failures are logged and tolerated; an aggregated error is returned
+// only when every requested extension fails to install.
+func (v *VSCodeWeb) InstallExtensions() error {
+ if err := v.installExtensions(); err != nil {
+ return fmt.Errorf("install extensions: %w", err)
+ }
+ return nil
+}
+
+// Start launches `code serve-web` as a background process bound to host:port.
+// Idempotent via command.StartBackgroundOnce. --accept-server-license-terms is
+// required so the background process never blocks on a license prompt.
+func (v *VSCodeWeb) Start() error {
+ location, err := prepareVSCodeWebLocation(v.userName)
+ if err != nil {
+ return err
+ }
+
+ binary := binaryPath(location)
+ if _, err := os.Stat(binary); err != nil {
+ return fmt.Errorf("find binary: %w", err)
+ }
+
+ return command.StartBackgroundOnce("vscode-web", func() (*exec.Cmd, error) {
+ log.Infof("Starting VS Code Web (serve-web) in background")
+ // serve-web downloads the VS Code server payload on the first browser
+ // connect, so the container needs outbound network access. Surfaced
+ // here because the fetch happens inside the detached process and a
+ // blocked connection would otherwise look like an unexplained hang.
+ log.Infof(
+ "VS Code Web fetches its server on first connect; the container needs " +
+ "outbound access to update.code.visualstudio.com",
+ )
+ // --without-connection-token: the only published path is the devsy
+ // port-forward tunnel, which is itself authenticated.
+ runCommand := fmt.Sprintf(
+ "%s serve-web --without-connection-token --accept-server-license-terms "+
+ "--host %q --port %q --server-data-dir %q",
+ binary, v.host, v.port, serverDataDir(location),
+ )
+ return suOrSh(v.userName, runCommand, location), nil
+ })
+}
+
+// isInstalled reports whether the `code` binary exists and was installed from
+// the same release URL currently requested. A missing or mismatched marker
+// forces a reinstall so VERSION / DOWNLOAD_* overrides take effect.
+func (v *VSCodeWeb) isInstalled(location, releaseURL string) bool {
+ if _, err := os.Stat(binaryPath(location)); err != nil {
+ return false
+ }
+ recorded, err := os.ReadFile(releaseMarkerPath(location))
+ if err != nil {
+ return false
+ }
+ return string(recorded) == releaseURL
+}
+
+func (v *VSCodeWeb) getReleaseURL() string {
+ version := Options.GetValue(v.values, VersionOption)
+
+ var url string
+ if runtime.GOARCH == archArm64 {
+ url = Options.GetValue(v.values, DownloadArm64Option)
+ if url == "" {
+ url = fmt.Sprintf(downloadArm64Template, version)
+ }
+ } else {
+ url = Options.GetValue(v.values, DownloadAmd64Option)
+ if url == "" {
+ url = fmt.Sprintf(downloadAmd64Template, version)
+ }
+ }
+ return url
+}
+
+func (v *VSCodeWeb) installExtensions() error {
+ if len(v.extensions) == 0 {
+ return nil
+ }
+
+ location, err := prepareVSCodeWebLocation(v.userName)
+ if err != nil {
+ return err
+ }
+
+ out := log.Writer(log.LevelInfo)
+ defer func() { _ = out.Close() }()
+
+ binary := binaryPath(location)
+ var failed []string
+ for _, extension := range v.extensions {
+ log.Infof("Install extension %q", extension)
+ runCommand := fmt.Sprintf(
+ "%s serve-web --server-data-dir %q --install-extension %q",
+ binary, serverDataDir(location), extension,
+ )
+ cmd := suOrSh(v.userName, runCommand, "")
+ cmd.Stdout = out
+ cmd.Stderr = out
+ if err := cmd.Run(); err != nil {
+ log.Errorf("failed installing extension: extension=%q, error=%v", extension, err)
+ failed = append(failed, extension)
+ } else {
+ log.Infof("installed extension: extension=%q", extension)
+ }
+ }
+
+ // Escalate only on total failure — partial failures stay as logged
+ // warnings to match openvscode's behavior.
+ if len(failed) == len(v.extensions) {
+ return fmt.Errorf("all %d extensions failed to install: %v", len(failed), failed)
+ }
+ return nil
+}
+
+// installSettings writes settings.json to serve-web's Machine config dir, which
+// lives under /data/Machine.
+func (v *VSCodeWeb) installSettings() error {
+ if len(v.settings) == 0 {
+ return nil
+ }
+
+ location, err := prepareVSCodeWebLocation(v.userName)
+ if err != nil {
+ return err
+ }
+ settingsDir := filepath.Join(serverDataDir(location), "data", "Machine")
+ // #nosec G301 -- match openvscode-server convention for parity.
+ if err := os.MkdirAll(settingsDir, 0o755); err != nil {
+ return err
+ }
+ if err := os.WriteFile(
+ filepath.Join(settingsDir, "settings.json"),
+ []byte(v.settings),
+ 0o600,
+ ); err != nil {
+ return err
+ }
+ if v.userName != "" {
+ if err := copy2.ChownR(location, v.userName); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// downloadAndExtract fetches the CLI tarball at url and extracts it under
+// location. The VS Code CLI tarball holds a single `code` binary at its root,
+// so no strip levels are applied. Cleans up a partial extraction on failure so
+// retries start fresh.
+func downloadAndExtract(url, location string) error {
+ resp, err := devsyhttp.GetHTTPClient().Get(url) // #nosec G107 -- URL comes from VersionOption.
+ if err != nil {
+ return err
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode >= http.StatusBadRequest {
+ return fmt.Errorf("download VS Code CLI: %s returned %s", url, resp.Status)
+ }
+
+ if err := extract.Extract(resp.Body, location); err != nil {
+ if rmErr := os.RemoveAll(location); rmErr != nil {
+ log.Warnf("cleanup partial install: path=%s err=%v", location, rmErr)
+ }
+ return fmt.Errorf("extract VS Code CLI: %w", err)
+ }
+ return nil
+}
+
+// suOrSh builds an *exec.Cmd that runs runCommand either as the unprivileged
+// user (via `su -c`) or as the current user (via `sh -c`). All arg
+// elements are constants from this package — runCommand itself is built from
+// constants plus internal values, not user-controlled binary paths.
+func suOrSh(userName, runCommand, workingDir string) *exec.Cmd {
+ var cmd *exec.Cmd
+ if userName != "" {
+ // #nosec G204 -- args are fixed constants; runCommand is internally built.
+ cmd = exec.Command("su", userName, "-c", runCommand)
+ } else {
+ // #nosec G204 -- args are fixed constants; runCommand is internally built.
+ cmd = exec.Command("sh", "-c", runCommand)
+ }
+ if workingDir != "" {
+ cmd.Dir = workingDir
+ }
+ return cmd
+}
+
+func userHome(userName string) (string, error) {
+ if userName != "" {
+ return command.GetHome(userName)
+ }
+ return util.UserHomeDir()
+}
+
+// binaryPath is the extracted VS Code CLI binary path.
+func binaryPath(location string) string {
+ return filepath.Join(location, "code")
+}
+
+// serverDataDir is where serve-web stores its server payload and Machine
+// settings, kept inside the install dir so it is cleaned up with it.
+func serverDataDir(location string) string {
+ return filepath.Join(location, "server-data")
+}
+
+// releaseMarkerPath holds the release URL the current binary was installed
+// from, used to detect VERSION / DOWNLOAD_* changes across runs.
+func releaseMarkerPath(location string) string {
+ return filepath.Join(location, ".release")
+}
+
+func writeReleaseMarker(location, releaseURL string) error {
+ return os.WriteFile(releaseMarkerPath(location), []byte(releaseURL), 0o600)
+}
+
+// prepareVSCodeWebLocation returns the install dir for VS Code Web, creating it
+// if needed. It is deliberately distinct from ~/.vscode-server (used by the
+// desktop VS Code remote server, whose binary is itself named code-server).
+func prepareVSCodeWebLocation(userName string) (string, error) {
+ homeFolder, err := userHome(userName)
+ if err != nil {
+ return "", err
+ }
+ folder := filepath.Join(homeFolder, ".vscode-web")
+ // #nosec G301 -- match openvscode-server convention for parity.
+ if err := os.MkdirAll(folder, 0o755); err != nil {
+ return "", err
+ }
+ return folder, nil
+}
diff --git a/pkg/ide/vscodeweb/vscodeweb_test.go b/pkg/ide/vscodeweb/vscodeweb_test.go
new file mode 100644
index 000000000..927e938f7
--- /dev/null
+++ b/pkg/ide/vscodeweb/vscodeweb_test.go
@@ -0,0 +1,112 @@
+package vscodeweb
+
+import (
+ "os"
+ "runtime"
+ "strings"
+ "testing"
+
+ "github.com/devsy-org/devsy/pkg/config"
+)
+
+func TestGetReleaseURLDefaultVersion(t *testing.T) {
+ v := NewVSCodeWeb(ServerOptions{})
+ url := v.getReleaseURL()
+
+ wantVersion := Options[VersionOption].Default
+ if !strings.Contains(url, wantVersion) {
+ t.Fatalf("expected url to contain default version %q, got %q", wantVersion, url)
+ }
+ if !strings.HasPrefix(url, "https://update.code.visualstudio.com/") {
+ t.Fatalf("unexpected release host in %q", url)
+ }
+
+ wantArch := "cli-linux-x64"
+ if runtime.GOARCH == archArm64 {
+ wantArch = "cli-linux-arm64"
+ }
+ if !strings.Contains(url, wantArch) {
+ t.Fatalf("expected url to contain %q, got %q", wantArch, url)
+ }
+}
+
+func TestGetReleaseURLVersionOverride(t *testing.T) {
+ v := NewVSCodeWeb(ServerOptions{
+ Values: map[string]config.OptionValue{
+ VersionOption: {Value: "1.99.0"},
+ },
+ })
+ url := v.getReleaseURL()
+ if !strings.Contains(url, "1.99.0") {
+ t.Fatalf("expected url to honor VERSION override, got %q", url)
+ }
+}
+
+func TestGetReleaseURLDownloadOverride(t *testing.T) {
+ const custom = "https://example.test/my-vscode-cli.tar.gz"
+ opt := DownloadAmd64Option
+ if runtime.GOARCH == archArm64 {
+ opt = DownloadArm64Option
+ }
+ v := NewVSCodeWeb(ServerOptions{
+ Values: map[string]config.OptionValue{
+ opt: {Value: custom},
+ },
+ })
+ if got := v.getReleaseURL(); got != custom {
+ t.Fatalf("expected explicit download url %q, got %q", custom, got)
+ }
+}
+
+func TestIsInstalledMatchesReleaseMarker(t *testing.T) {
+ location := t.TempDir()
+ v := NewVSCodeWeb(ServerOptions{})
+ releaseURL := v.getReleaseURL()
+
+ if v.isInstalled(location, releaseURL) {
+ t.Fatal("expected not installed when binary is missing")
+ }
+
+ if err := os.WriteFile(binaryPath(location), []byte("stub"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if v.isInstalled(location, releaseURL) {
+ t.Fatal("expected not installed when release marker is missing")
+ }
+
+ if err := writeReleaseMarker(location, releaseURL); err != nil {
+ t.Fatal(err)
+ }
+ if !v.isInstalled(location, releaseURL) {
+ t.Fatal("expected installed when binary and matching marker exist")
+ }
+
+ if v.isInstalled(location, "https://example.test/other-version") {
+ t.Fatal("expected reinstall when requested release differs from marker")
+ }
+}
+
+func TestWriteReleaseMarkerRoundTrip(t *testing.T) {
+ location := t.TempDir()
+ const url = "https://update.code.visualstudio.com/1.99.0/cli-linux-x64/stable"
+ if err := writeReleaseMarker(location, url); err != nil {
+ t.Fatal(err)
+ }
+ got, err := os.ReadFile(releaseMarkerPath(location)) // #nosec G304 -- test-controlled temp path
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != url {
+ t.Fatalf("marker mismatch: got %q want %q", string(got), url)
+ }
+}
+
+func TestNewVSCodeWebDefaults(t *testing.T) {
+ v := NewVSCodeWeb(ServerOptions{})
+ if v.host != "0.0.0.0" {
+ t.Fatalf("expected default host 0.0.0.0, got %q", v.host)
+ }
+ if v.port == "" {
+ t.Fatalf("expected default port to be set")
+ }
+}