From 0f8159157a905f6a588817550f874f11a8cbce0d Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 1 Sep 2026 16:37:31 +0000 Subject: [PATCH 1/6] fix(ssh): normalize Windows proxy paths --- .github/workflows/pr-ci.yml | 7 +++ e2e/tests/ssh/proxy_command.go | 107 +++++++++++++++++++++++++++++++++ pkg/ssh/config.go | 23 ++++++- pkg/ssh/config_test.go | 40 ++++++++++++ pkg/ssh/config_windows_test.go | 28 +++++++++ 5 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 e2e/tests/ssh/proxy_command.go create mode 100644 pkg/ssh/config_windows_test.go diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 7cf8dab6a..a3069ab21 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -135,6 +135,7 @@ jobs: name: e2e-test-${{ steps.os.outputs.runner_os }} path: ./e2e/e2e.test* + licenses: name: Third-party licenses needs: [changes, precommit, lint] @@ -578,6 +579,12 @@ jobs: free-disk-space: false install-kind: true requires-secret: false + - label: ssh-proxy-command + runner: windows-latest + free-disk-space: false + install-kind: true + requires-secret: false + runs-on: ${{ matrix.runner }} timeout-minutes: ${{ matrix.job-timeout-minutes || 45 }} diff --git a/e2e/tests/ssh/proxy_command.go b/e2e/tests/ssh/proxy_command.go new file mode 100644 index 000000000..3f6d37d4d --- /dev/null +++ b/e2e/tests/ssh/proxy_command.go @@ -0,0 +1,107 @@ +package ssh + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe( + "devsy Windows SSH ProxyCommand", + ginkgo.Label("ssh-proxy-command"), + func() { + var initialDir string + + ginkgo.BeforeEach(func() { + var err error + initialDir, err = os.Getwd() + framework.ExpectNoError(err) + }) + + ginkgo.It( + "should launch a workspace through an executable path containing spaces", + ginkgo.SpecTimeout(framework.TimeoutLong()), + func(ctx context.Context) { + if runtime.GOOS != osWindows { + ginkgo.Skip("skipping on non-Windows") + } + + tempDir, err := framework.CopyToTempDir("tests/ssh/testdata/local-test") + framework.ExpectNoError(err) + + baseFramework := framework.NewDefaultFramework(initialDir + "/bin") + sourcePath := filepath.Join(baseFramework.DevsyBinDir, baseFramework.DevsyBinName) + fixtureDir := filepath.Join(ginkgo.GinkgoT().TempDir(), "Devsy Test") + framework.ExpectNoError(os.MkdirAll(fixtureDir, 0o700)) + fixturePath := filepath.Join(fixtureDir, baseFramework.DevsyBinName) + binary, err := os.ReadFile(sourcePath) + framework.ExpectNoError(err) + framework.ExpectNoError(os.WriteFile(fixturePath, binary, 0o700)) + gomega.Expect(fixturePath).To(gomega.ContainSubstring(" ")) + + f := framework.NewDefaultFramework(fixtureDir) + _ = f.DevsyProviderAdd(ctx, "docker") + err = f.DevsyProviderUse(ctx, "docker") + framework.ExpectNoError(err) + + sshConfigPath := filepath.Join(ginkgo.GinkgoT().TempDir(), "ssh config") + ginkgo.DeferCleanup(func(cleanupCtx context.Context) { + _ = f.DevsyWorkspaceDelete(cleanupCtx, tempDir) + framework.CleanupTempDir(initialDir, tempDir) + }) + + upCtx, cancelUp := context.WithTimeout(ctx, 5*time.Minute) + defer cancelUp() + err = f.DevsyUp(upCtx, tempDir, "--ssh-config", sshConfigPath) + framework.ExpectNoError(err) + + configBytes, err := os.ReadFile(filepath.Clean(sshConfigPath)) + framework.ExpectNoError(err) + config := string(configBytes) + expectedPath := strings.ReplaceAll(fixturePath, `\`, "/") + gomega.Expect(config).To( + gomega.ContainSubstring(`ProxyCommand "`+expectedPath+`"`), + "SSH config should use forward slashes for the executable path", + ) + gomega.Expect(config).NotTo( + gomega.ContainSubstring(fixturePath), + "SSH config should not contain the native Windows executable path", + ) + + sshPath, err := exec.LookPath("ssh.exe") + framework.ExpectNoError(err) + host := filepath.Base(tempDir) + ".devsy" + sshCtx, cancelSSH := context.WithTimeout(ctx, 30*time.Second) + defer cancelSSH() + cmd := exec.CommandContext( + sshCtx, + sshPath, + "-F", sshConfigPath, + "-o", "BatchMode=yes", + host, + "printf", + "proxy-command-ok", + ) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err = cmd.Run() + framework.ExpectNoError( + err, + "OpenSSH should launch ProxyCommand; stdout=%q stderr=%q", + stdout.String(), stderr.String(), + ) + gomega.Expect(strings.TrimSpace(stdout.String())).To(gomega.Equal("proxy-command-ok")) + }, + ) + }, +) diff --git a/pkg/ssh/config.go b/pkg/ssh/config.go index 2b55d9300..9827fcd0e 100644 --- a/pkg/ssh/config.go +++ b/pkg/ssh/config.go @@ -112,11 +112,32 @@ type proxyCommandBuilder struct { options []string } +func normalizeSSHExecPath(execPath string) string { + return normalizeSSHExecPathForOS(execPath, runtime.GOOS) +} + +func normalizeSSHExecPathForOS(execPath, goos string) string { + if goos == "windows" { + return strings.ReplaceAll(execPath, `\`, "/") + } + + return execPath +} + func newProxyCommandBuilder(execPath, context, user, workspace string) *proxyCommandBuilder { + normalizedExecPath := normalizeSSHExecPath(execPath) + log.Debugw( + "ssh proxy command config", + "os", runtime.GOOS, + "executable_raw", execPath, + "executable_normalized", normalizedExecPath, + "workspace", workspace, + ) + return &proxyCommandBuilder{ baseCommand: fmt.Sprintf( "\"%s\" workspace ssh %s %s %s %s %s %s", - execPath, + normalizedExecPath, names.Flag(names.Stdio), names.Flag(names.Context), context, diff --git a/pkg/ssh/config_test.go b/pkg/ssh/config_test.go index 4af4b5778..22f9c6c87 100644 --- a/pkg/ssh/config_test.go +++ b/pkg/ssh/config_test.go @@ -369,3 +369,43 @@ func (s *SSHConfigTestSuite) TestAddHostSection() { }) } } + +func TestNormalizeSSHExecPathForOS(t *testing.T) { + tests := []struct { + name string + goos string + input string + expected string + }{ + { + name: "windows path", + goos: "windows", + input: `C:\Users\test\AppData\Local\Programs\Devsy\devsy.exe`, + expected: `C:/Users/test/AppData/Local/Programs/Devsy/devsy.exe`, + }, + { + name: "windows path with spaces", + goos: "windows", + input: `C:\Users\Test User\AppData\Local\Programs\Devsy\devsy.exe`, + expected: `C:/Users/Test User/AppData/Local/Programs/Devsy/devsy.exe`, + }, + { + name: "linux path", + goos: "linux", + input: `/usr/local/bin/devsy`, + expected: `/usr/local/bin/devsy`, + }, + { + name: "macos path", + goos: "darwin", + input: `/Applications/Devsy.app/Contents/MacOS/devsy`, + expected: `/Applications/Devsy.app/Contents/MacOS/devsy`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, normalizeSSHExecPathForOS(tt.input, tt.goos)) + }) + } +} diff --git a/pkg/ssh/config_windows_test.go b/pkg/ssh/config_windows_test.go new file mode 100644 index 000000000..4f2d4a99a --- /dev/null +++ b/pkg/ssh/config_windows_test.go @@ -0,0 +1,28 @@ +//go:build windows + +package ssh + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAddHostSectionNormalizesWindowsExecPath(t *testing.T) { + execPath := `C:\Users\Test User\AppData\Local\Programs\Devsy\resources\bin\devsy.exe` + result, err := addHostSection("", execPath, addHostParams{ + host: "testhost", + user: "ubuntu", + context: "default", + workspace: "testworkspace", + workdir: "/workspaces/project", + }) + require.NoError(t, err) + require.Contains( + t, + result, + `ProxyCommand "C:/Users/Test User/AppData/Local/Programs/Devsy/resources/bin/devsy.exe" workspace ssh --stdio --context default --user ubuntu testworkspace`, + ) + require.NotContains(t, result, `C:\Users\Test User`) + require.Contains(t, result, `--workdir "/workspaces/project"`) +} From d4245d211d21b06eb159651533015ad0953c2bfb Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 1 Sep 2026 16:50:07 +0000 Subject: [PATCH 2/6] fix: resolve lint findings --- e2e/tests/ssh/proxy_command.go | 8 ++++++-- pkg/ssh/config.go | 6 ++++-- pkg/ssh/config_test.go | 4 ++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/e2e/tests/ssh/proxy_command.go b/e2e/tests/ssh/proxy_command.go index 3f6d37d4d..ad992e007 100644 --- a/e2e/tests/ssh/proxy_command.go +++ b/e2e/tests/ssh/proxy_command.go @@ -43,9 +43,10 @@ var _ = ginkgo.Describe( fixtureDir := filepath.Join(ginkgo.GinkgoT().TempDir(), "Devsy Test") framework.ExpectNoError(os.MkdirAll(fixtureDir, 0o700)) fixturePath := filepath.Join(fixtureDir, baseFramework.DevsyBinName) + // #nosec G304 -- controlled path to the E2E fixture binary binary, err := os.ReadFile(sourcePath) framework.ExpectNoError(err) - framework.ExpectNoError(os.WriteFile(fixturePath, binary, 0o700)) + framework.ExpectNoError(os.WriteFile(fixturePath, binary, 0o600)) gomega.Expect(fixturePath).To(gomega.ContainSubstring(" ")) f := framework.NewDefaultFramework(fixtureDir) @@ -82,6 +83,7 @@ var _ = ginkgo.Describe( host := filepath.Base(tempDir) + ".devsy" sshCtx, cancelSSH := context.WithTimeout(ctx, 30*time.Second) defer cancelSSH() + // #nosec G204 -- controlled OpenSSH invocation for the E2E test cmd := exec.CommandContext( sshCtx, sshPath, @@ -100,7 +102,9 @@ var _ = ginkgo.Describe( "OpenSSH should launch ProxyCommand; stdout=%q stderr=%q", stdout.String(), stderr.String(), ) - gomega.Expect(strings.TrimSpace(stdout.String())).To(gomega.Equal("proxy-command-ok")) + gomega.Expect(strings.TrimSpace(stdout.String())).To( + gomega.Equal("proxy-command-ok"), + ) }, ) }, diff --git a/pkg/ssh/config.go b/pkg/ssh/config.go index 9827fcd0e..9c354bea1 100644 --- a/pkg/ssh/config.go +++ b/pkg/ssh/config.go @@ -25,6 +25,8 @@ var ( MarkerEndPrefix = "# Devsy End " ) +const windowsGOOS = "windows" + type SSHConfigParams struct { SSHConfigPath string SSHConfigIncludePath string @@ -117,7 +119,7 @@ func normalizeSSHExecPath(execPath string) string { } func normalizeSSHExecPathForOS(execPath, goos string) string { - if goos == "windows" { + if goos == windowsGOOS { return strings.ReplaceAll(execPath, `\`, "/") } @@ -317,7 +319,7 @@ func mergeSSHConfig(lines, newLines []string, position int) string { merged := slices.Insert(lines, position, newLines...) newLineSep := "\n" - if runtime.GOOS == "windows" { + if runtime.GOOS == windowsGOOS { newLineSep = "\r\n" } diff --git a/pkg/ssh/config_test.go b/pkg/ssh/config_test.go index 22f9c6c87..a6ddcb5a6 100644 --- a/pkg/ssh/config_test.go +++ b/pkg/ssh/config_test.go @@ -379,13 +379,13 @@ func TestNormalizeSSHExecPathForOS(t *testing.T) { }{ { name: "windows path", - goos: "windows", + goos: windowsGOOS, input: `C:\Users\test\AppData\Local\Programs\Devsy\devsy.exe`, expected: `C:/Users/test/AppData/Local/Programs/Devsy/devsy.exe`, }, { name: "windows path with spaces", - goos: "windows", + goos: windowsGOOS, input: `C:\Users\Test User\AppData\Local\Programs\Devsy\devsy.exe`, expected: `C:/Users/Test User/AppData/Local/Programs/Devsy/devsy.exe`, }, From 61b1cb8c5536836da8df1ef65365a0144f5f1007 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 1 Sep 2026 16:56:12 +0000 Subject: [PATCH 3/6] ci: update golangci-lint for Go 1.27 --- .github/workflows/pr-ci.yml | 2 +- .golangci-version | 2 +- .pre-commit-config.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index a3069ab21..d0b5d6dc0 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -56,7 +56,7 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9 with: - version: v2.12.2 + version: v2.13.2 only-new-issues: true can-read-secret: diff --git a/.golangci-version b/.golangci-version index 6f9cc441d..095c2a17d 100644 --- a/.golangci-version +++ b/.golangci-version @@ -1 +1 @@ -v2.12.2 +v2.13.2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 55368d5b3..92304022f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -59,7 +59,7 @@ repos: - id: commitizen stages: [commit-msg] - repo: https://github.com/golangci/golangci-lint - rev: v2.12.2 + rev: v2.13.2 hooks: - id: golangci-lint args: ["--timeout=10m"] From e17e1c6725e565156f8a095f7c124c3caab22d46 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 1 Sep 2026 16:57:40 +0000 Subject: [PATCH 4/6] ci: pin Go version for lint hooks --- .github/workflows/pr-ci.yml | 2 +- .golangci-version | 2 +- .pre-commit-config.yaml | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index d0b5d6dc0..a3069ab21 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -56,7 +56,7 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9 with: - version: v2.13.2 + version: v2.12.2 only-new-issues: true can-read-secret: diff --git a/.golangci-version b/.golangci-version index 095c2a17d..6f9cc441d 100644 --- a/.golangci-version +++ b/.golangci-version @@ -1 +1 @@ -v2.13.2 +v2.12.2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 92304022f..c4cf565dc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -59,11 +59,13 @@ repos: - id: commitizen stages: [commit-msg] - repo: https://github.com/golangci/golangci-lint - rev: v2.13.2 + rev: v2.12.2 hooks: - id: golangci-lint + language_version: 1.26.5 args: ["--timeout=10m"] - id: golangci-lint-fmt + language_version: 1.26.5 - repo: local hooks: - id: golangci-lint-ci-parity From c182761197607780afaf09ff09efefe77c94a53c Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 1 Sep 2026 17:03:16 +0000 Subject: [PATCH 5/6] ci: pin Renovate golangci-lint updates --- renovate.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/renovate.json b/renovate.json index 621488003..475b16ff3 100644 --- a/renovate.json +++ b/renovate.json @@ -33,6 +33,15 @@ "automerge": true, "prPriority": 4 }, + { + "matchManagers": ["pre-commit"], + "matchPackageNames": ["https://github.com/golangci/golangci-lint"], + "matchFileNames": [".pre-commit-config.yaml"], + "allowedVersions": "v2.12.2", + "automerge": false, + "description": "keep golangci-lint aligned with the pinned Go 1.26.5 toolchain" + }, + { "matchManagers": ["gomod"], "automerge": true, From 6f15f897f241768905464774316af50b9ebb7b67 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 1 Sep 2026 19:22:55 +0000 Subject: [PATCH 6/6] test: use Podman in Windows SSH E2E --- e2e/tests/ssh/proxy_command.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/e2e/tests/ssh/proxy_command.go b/e2e/tests/ssh/proxy_command.go index ad992e007..88e4aa353 100644 --- a/e2e/tests/ssh/proxy_command.go +++ b/e2e/tests/ssh/proxy_command.go @@ -49,9 +49,7 @@ var _ = ginkgo.Describe( framework.ExpectNoError(os.WriteFile(fixturePath, binary, 0o600)) gomega.Expect(fixturePath).To(gomega.ContainSubstring(" ")) - f := framework.NewDefaultFramework(fixtureDir) - _ = f.DevsyProviderAdd(ctx, "docker") - err = f.DevsyProviderUse(ctx, "docker") + f, err := framework.SetupDockerProvider(fixtureDir, "podman") framework.ExpectNoError(err) sshConfigPath := filepath.Join(ginkgo.GinkgoT().TempDir(), "ssh config")