From 9d04ecaf55b3e5fd4c528485e2b0d662f0afe6c9 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 24 Jun 2026 09:07:06 -0500 Subject: [PATCH 1/5] chore: update email address --- SECURITY.md | 2 +- cmd/pro/start.go | 2 +- desktop/package.json | 2 +- pkg/driver/kubernetes/pullsecrets.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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/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/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 { From 9132ca8012b2ab094f510ffa6f777dc8decf903c Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 26 Jun 2026 06:14:45 -0500 Subject: [PATCH 2/5] feat(ide): add vscode-web (code serve-web) browser IDE Adds the official "VS Code for the Web" as `--ide=vscode-web`, running the official VS Code CLI as `code serve-web`. Unlike openvscode (Gitpod fork, Open VSX) and code-server (Coder, Open VSX), this serves the full Microsoft Marketplace, so Microsoft-only extensions (Pylance, MS C/C++) are available. Wired through every layer that code-server uses, mirroring the one-package-per-browser-IDE convention: - config enum, ideparse registry, agent installIDE + async extension install - browser opener + tunnel label, SSH auth-sock reuse - desktop IDE pickers (Svelte) with a distinct browser-window icon Installs to ~/.vscode-web (distinct from the desktop vscode server's ~/.vscode-server), default port 10802, pinned VERSION (overridable). serve-web fetches its server payload on first connect, so the container needs outbound network access; this requirement is logged before launch. --- .golangci.yaml | 8 + cmd/internal/agentcontainer/container.go | 1 + cmd/internal/agentcontainer/setup.go | 61 +++ .../agentcontainer/vscodeweb_async.go | 59 +++ .../renderer/public/icons/ides/vscode-web.svg | 37 ++ .../workspace/WorkspaceWizard.svelte | 1 + .../renderer/src/pages/SettingsPage.svelte | 1 + .../src/pages/WorkspaceDetailPage.svelte | 1 + e2e/tests/ide/ide.go | 3 + pkg/config/ide.go | 1 + pkg/ide/ideparse/parse.go | 8 + pkg/ide/opener/browser_tunnel_state.go | 4 + pkg/ide/opener/opener.go | 22 ++ pkg/ide/types.go | 2 + pkg/ide/vscodeweb/vscodeweb.go | 356 ++++++++++++++++++ pkg/ide/vscodeweb/vscodeweb_test.go | 68 ++++ 16 files changed, 633 insertions(+) create mode 100644 cmd/internal/agentcontainer/vscodeweb_async.go create mode 100644 desktop/src/renderer/public/icons/ides/vscode-web.svg create mode 100644 pkg/ide/vscodeweb/vscodeweb.go create mode 100644 pkg/ide/vscodeweb/vscodeweb_test.go diff --git a/.golangci.yaml b/.golangci.yaml index 94eed5599..504fa4f94 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -37,6 +37,14 @@ 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" + - linters: + - dupl + path: "cmd/internal/agentcontainer/setup.go" - path: "hack/" linters: - forbidigo 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..b10537577 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) @@ -831,6 +834,64 @@ func (cmd *SetupContainerCmd) setupCodeServer( return cs.Start() } +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/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/ide/ideparse/parse.go b/pkg/ide/ideparse/parse.go index 329771f0e..7e83b3d2f 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.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..1927d6787 100644 --- a/pkg/ide/opener/opener.go +++ b/pkg/ide/opener/opener.go @@ -22,6 +22,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 +98,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): @@ -441,6 +444,25 @@ func openCodeServerBrowser( }) } +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, folder) + }, + }) +} + func startFleet(ctx context.Context, params IDEParams) (string, error) { stdout := &bytes.Buffer{} sshCmd, err := tunnel.CreateSSHCommand( 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..e8a6fa3e4 --- /dev/null +++ b/pkg/ide/vscodeweb/vscodeweb.go @@ -0,0 +1,356 @@ +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. Idempotent: returns nil without +// re-downloading when the binary is already present. +func (v *VSCodeWeb) Install() error { + location, err := prepareVSCodeWebLocation(v.userName) + if err != nil { + return err + } + + if _, err := os.Stat(binaryPath(location)); err == nil { + return nil + } + + vscode.InstallAPKRequirements() + + if err := downloadAndExtract(v.getReleaseURL(), location); err != nil { + return err + } + + if v.userName != "" { + if err := copy2.ChownR(location, v.userName); err != nil { + return fmt.Errorf("chown: %w", err) + } + } + + if err := v.installSettings(); err != nil { + return fmt.Errorf("install settings: %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 + }) +} + +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.Info("Install extension " + 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=%s, error=%v", extension, err) + failed = append(failed, extension) + } else { + log.Infof("installed extension: extension=%s", 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") +} + +// 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..b974a6e50 --- /dev/null +++ b/pkg/ide/vscodeweb/vscodeweb_test.go @@ -0,0 +1,68 @@ +package vscodeweb + +import ( + "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 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") + } +} From 4c423bb02aa4da711b0d165c0a06341fc1a09252 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 26 Jun 2026 06:16:52 -0500 Subject: [PATCH 3/5] feat(ide): point vscode-web registry icon at hosted vscode-web.svg The web/pro UI loads IDE icons from devsy.sh/assets. Asset must be uploaded there before the icon renders in that surface (desktop bundle already ships its own copy). --- pkg/ide/ideparse/parse.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/ide/ideparse/parse.go b/pkg/ide/ideparse/parse.go index 7e83b3d2f..f78c7c556 100644 --- a/pkg/ide/ideparse/parse.go +++ b/pkg/ide/ideparse/parse.go @@ -72,7 +72,7 @@ var AllowedIDEs = []AllowedIDE{ Name: config.IDEVSCodeWeb, DisplayName: "VS Code Web", Options: vscodeweb.Options, - Icon: config.WebsiteAssetsURL + "/vscode.svg", + Icon: config.WebsiteAssetsURL + "/vscode-web.svg", Group: config.IDEGroupPrimary, }, { From 067720ad089a86c8bf35cb3dce0af85fb2607236 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 26 Jun 2026 10:25:44 -0500 Subject: [PATCH 4/5] fix(ide): escape vscode-web folder URL, harden extension logging, narrow dupl - url.QueryEscape the folder query param in browser IDE TargetURLFn so workspace paths with spaces/#/& open the correct folder (all 3 browser IDEs) - log extension IDs with %q to prevent CRLF log forging from config input - replace file-level dupl exclusion on setup.go with per-function //nolint:dupl on the intentionally-mirrored setupCodeServer/setupVSCodeWeb --- .golangci.yaml | 3 --- cmd/internal/agentcontainer/setup.go | 5 +++++ pkg/ide/opener/opener.go | 7 ++++--- pkg/ide/vscodeweb/vscodeweb.go | 6 +++--- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index 504fa4f94..efaaa2f76 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -42,9 +42,6 @@ linters: - linters: - dupl path: "cmd/internal/agentcontainer/(codeserver|vscodeweb|openvscode)_async.go" - - linters: - - dupl - path: "cmd/internal/agentcontainer/setup.go" - path: "hack/" linters: - forbidigo diff --git a/cmd/internal/agentcontainer/setup.go b/cmd/internal/agentcontainer/setup.go index b10537577..2bb6a36b0 100644 --- a/cmd/internal/agentcontainer/setup.go +++ b/cmd/internal/agentcontainer/setup.go @@ -776,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, @@ -834,6 +835,10 @@ 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, diff --git a/pkg/ide/opener/opener.go b/pkg/ide/opener/opener.go index 1927d6787..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" @@ -420,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)) }, }) } @@ -439,7 +440,7 @@ 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)) }, }) } @@ -458,7 +459,7 @@ func openVSCodeWebBrowser( Label: LabelVSCodeWeb, LogName: "vscode-web", 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)) }, }) } diff --git a/pkg/ide/vscodeweb/vscodeweb.go b/pkg/ide/vscodeweb/vscodeweb.go index e8a6fa3e4..c8ba5c99c 100644 --- a/pkg/ide/vscodeweb/vscodeweb.go +++ b/pkg/ide/vscodeweb/vscodeweb.go @@ -223,7 +223,7 @@ func (v *VSCodeWeb) installExtensions() error { binary := binaryPath(location) var failed []string for _, extension := range v.extensions { - log.Info("Install extension " + extension) + log.Infof("Install extension %q", extension) runCommand := fmt.Sprintf( "%s serve-web --server-data-dir %q --install-extension %q", binary, serverDataDir(location), extension, @@ -232,10 +232,10 @@ func (v *VSCodeWeb) installExtensions() error { cmd.Stdout = out cmd.Stderr = out if err := cmd.Run(); err != nil { - log.Errorf("failed installing extension: extension=%s, error=%v", extension, err) + log.Errorf("failed installing extension: extension=%q, error=%v", extension, err) failed = append(failed, extension) } else { - log.Infof("installed extension: extension=%s", extension) + log.Infof("installed extension: extension=%q", extension) } } From 06aacb08353e7322876ae28cbe7a15310d59067d Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 26 Jun 2026 10:41:49 -0500 Subject: [PATCH 5/5] fix(ide): reinstall vscode-web when VERSION/DOWNLOAD_* changes Track the release URL used at install time in a .release marker and skip download only when the existing install matches. settings.json is now rewritten on every Install so workspace settings stay current after the first install. Adds marker round-trip and reinstall-detection tests. Addresses PR review (CodeRabbit #4). --- pkg/ide/vscodeweb/vscodeweb.go | 53 ++++++++++++++++++++++------- pkg/ide/vscodeweb/vscodeweb_test.go | 44 ++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 12 deletions(-) diff --git a/pkg/ide/vscodeweb/vscodeweb.go b/pkg/ide/vscodeweb/vscodeweb.go index c8ba5c99c..5eb1a55fc 100644 --- a/pkg/ide/vscodeweb/vscodeweb.go +++ b/pkg/ide/vscodeweb/vscodeweb.go @@ -113,22 +113,31 @@ func NewVSCodeWeb(opts ServerOptions) *VSCodeWeb { } // Install downloads the VS Code CLI tarball, extracts the `code` binary under -// /.vscode-web, and writes settings.json. Idempotent: returns nil without -// re-downloading when the binary is already present. +// /.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 } - if _, err := os.Stat(binaryPath(location)); err == nil { - return nil - } + releaseURL := v.getReleaseURL() + if !v.isInstalled(location, releaseURL) { + vscode.InstallAPKRequirements() - vscode.InstallAPKRequirements() + if err := downloadAndExtract(releaseURL, location); err != nil { + return err + } - if err := downloadAndExtract(v.getReleaseURL(), 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 != "" { @@ -137,10 +146,6 @@ func (v *VSCodeWeb) Install() error { } } - if err := v.installSettings(); err != nil { - return fmt.Errorf("install settings: %w", err) - } - return nil } @@ -189,6 +194,20 @@ func (v *VSCodeWeb) Start() error { }) } +// 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) @@ -339,6 +358,16 @@ 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). diff --git a/pkg/ide/vscodeweb/vscodeweb_test.go b/pkg/ide/vscodeweb/vscodeweb_test.go index b974a6e50..927e938f7 100644 --- a/pkg/ide/vscodeweb/vscodeweb_test.go +++ b/pkg/ide/vscodeweb/vscodeweb_test.go @@ -1,6 +1,7 @@ package vscodeweb import ( + "os" "runtime" "strings" "testing" @@ -57,6 +58,49 @@ func TestGetReleaseURLDownloadOverride(t *testing.T) { } } +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" {