From 690adaf21fbcd7eb1f216fadb2e5961bdcd7c574 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 24 Jun 2026 19:18:32 -0500 Subject: [PATCH 1/4] fix(podman): chown workspace for non-root remoteUser on macOS/Windows The in-container SSH server (which VS Code Remote-SSH connects through) failed with "start command: fork/exec /usr/bin/bash: permission denied" for Podman workspaces with a non-root remoteUser on macOS/Windows hosts. Root cause: rootless `podman machine` surfaces the host workspace bind mount as root-owned (root:root 0750) inside the container. Go's exec performs chdir(cmd.Dir) in the forked child before execve; the non-root remote user cannot enter the workspace folder, so the chdir returns EACCES, which Go reports against the shell path. addChownFlag previously gated `--chown-workspace` on `runtime.GOOS == "linux" || !isDockerDriver`. Podman uses the docker driver, so on a macOS/Windows host neither clause held and the workspace was never chowned to the remote user. Extend the condition to also chown for the Podman runtime regardless of host OS. devsy's own terminal was unaffected because the podman provider runs commands through the in-process emulated shell (`devsy internal sh`), which never execs a real /usr/bin/bash. Tests: - Unit: shouldChownWorkspace and isPodmanRuntime tables. - E2E: new rootless-podman spec with a non-root remoteUser fixture asserting the workspace folder is owned by the remote user. --- e2e/tests/up/provider_podman.go | 41 ++++++++++ .../docker-nonroot-user/.devcontainer.json | 7 ++ .../testdata/docker-nonroot-user/Dockerfile | 7 ++ pkg/devcontainer/setup.go | 29 ++++++- pkg/devcontainer/setup_test.go | 81 +++++++++++++++++++ 5 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 e2e/tests/up/testdata/docker-nonroot-user/.devcontainer.json create mode 100644 e2e/tests/up/testdata/docker-nonroot-user/Dockerfile diff --git a/e2e/tests/up/provider_podman.go b/e2e/tests/up/provider_podman.go index 598b1bb21..27c1ac180 100644 --- a/e2e/tests/up/provider_podman.go +++ b/e2e/tests/up/provider_podman.go @@ -47,6 +47,47 @@ var _ = ginkgo.Describe( }, ginkgo.SpecTimeout(framework.TimeoutShort()), ) + + // Regression: rootless podman bind-mounts the host workspace + // folder as root-owned inside the container. A non-root + // remoteUser could not chdir into it, so the in-container SSH + // server's `fork/exec /usr/bin/bash` failed with "permission + // denied" and `devsy ssh` (and VS Code Remote-SSH) broke. The + // agent must chown the workspace to the remote user even though + // podman runs through the docker driver. + ginkgo.It( + "should ssh into a workspace with a non-root remoteUser", + func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-nonroot-user", + initialDir, + f, + ) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + // A non-PTY command exec is exactly the path that failed: + // it runs `bash -c ` with the workspace folder as the + // working directory, as the non-root remote user. + whoami, err := f.DevsySSH(ctx, tempDir, "whoami") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(whoami)).To(gomega.Equal("devsyuser")) + + // The workspace folder must be owned by the remote user. + // This is the actual fix: without the chown the folder + // stays root-owned (rootless podman bind mount), and the + // remote user cannot enter it. Asserting ownership rather + // than mere chdir success avoids a false pass when the + // bind source happens to be world-searchable. + owner, err := f.DevsySSH(ctx, tempDir, `stat -c %U "$PWD"`) + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(owner)).To(gomega.Equal("devsyuser"), + "workspace folder should be chowned to the remote user") + }, + ginkgo.SpecTimeout(framework.TimeoutLong()), + ) }) ginkgo.Context("build", func() { diff --git a/e2e/tests/up/testdata/docker-nonroot-user/.devcontainer.json b/e2e/tests/up/testdata/docker-nonroot-user/.devcontainer.json new file mode 100644 index 000000000..3703fc592 --- /dev/null +++ b/e2e/tests/up/testdata/docker-nonroot-user/.devcontainer.json @@ -0,0 +1,7 @@ +{ + "name": "Non-root user", + "build": { + "dockerfile": "Dockerfile" + }, + "remoteUser": "devsyuser" +} diff --git a/e2e/tests/up/testdata/docker-nonroot-user/Dockerfile b/e2e/tests/up/testdata/docker-nonroot-user/Dockerfile new file mode 100644 index 000000000..a93682ddc --- /dev/null +++ b/e2e/tests/up/testdata/docker-nonroot-user/Dockerfile @@ -0,0 +1,7 @@ +FROM ghcr.io/devsy-org/test-images/base:ubuntu + +# Create a non-root user. This is the configuration that exposes the podman +# bind-mount ownership bug: rootless podman surfaces the host workspace bind +# mount as root-owned inside the container, so a non-root remote user cannot +# chdir into the workspace folder unless the agent chowns it during setup. +RUN useradd --create-home --shell /bin/bash devsyuser diff --git a/pkg/devcontainer/setup.go b/pkg/devcontainer/setup.go index f42207f16..bdf6322d6 100644 --- a/pkg/devcontainer/setup.go +++ b/pkg/devcontainer/setup.go @@ -20,6 +20,7 @@ import ( "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/devcontainer/crane" "github.com/devsy-org/devsy/pkg/devcontainer/sshtunnel" + "github.com/devsy-org/devsy/pkg/docker" "github.com/devsy-org/devsy/pkg/driver" "github.com/devsy-org/devsy/pkg/ide" "github.com/devsy-org/devsy/pkg/log" @@ -331,11 +332,37 @@ func (r *runner) addSetupFlags(args *[]string) { } func (r *runner) addChownFlag(args *[]string, isDockerDriver bool) { - if runtime.GOOS == "linux" || !isDockerDriver { + if shouldChownWorkspace(runtime.GOOS, isDockerDriver, r.isPodmanRuntime()) { *args = append(*args, "--chown-workspace") } } +// shouldChownWorkspace decides whether the agent should chown the workspace +// folder to the remote user during setup. +// +// Docker Desktop's macOS/Windows file sharing already presents bind mounts +// owned by the container user, so a chown is unnecessary there; the flag is +// therefore only added on Linux hosts for the docker driver. Podman is the +// exception: even though it runs through the docker driver, its `podman +// machine` bind mounts surface host files as root-owned inside the container, +// so a non-root remote user cannot enter the workspace folder unless we chown +// it. Request the chown for podman regardless of host OS. +// +// Note: the chown writes through the bind mount to the host inode, so it also +// changes ownership of the host-side workspace contents (cf. loft-sh/devpod +// #1879). The agent only chowns when a non-root remote user is resolved, so +// root-user containers are unaffected. +func shouldChownWorkspace(goos string, isDockerDriver, isPodman bool) bool { + return goos == "linux" || !isDockerDriver || isPodman +} + +// isPodmanRuntime reports whether the workspace's docker driver is backed by +// the Podman runtime, as configured by the built-in podman provider +// (agent.docker.runtime: podman). +func (r *runner) isPodmanRuntime() bool { + return strings.EqualFold(r.WorkspaceConfig.Agent.Docker.Runtime, string(docker.RuntimePodman)) +} + func (r *runner) addDriverFlags(args *[]string, isDockerDriver bool) { if !isDockerDriver { *args = append(*args, "--stream-mounts") diff --git a/pkg/devcontainer/setup_test.go b/pkg/devcontainer/setup_test.go index 963d6e8a3..9ddbc1010 100644 --- a/pkg/devcontainer/setup_test.go +++ b/pkg/devcontainer/setup_test.go @@ -10,6 +10,87 @@ import ( func boolPtr(v bool) *bool { return &v } +func TestShouldChownWorkspace(t *testing.T) { + cases := []struct { + name string + goos string + isDockerDriver bool + isPodman bool + want bool + }{ + { + name: "linux docker host always chowns", + goos: "linux", isDockerDriver: true, isPodman: false, want: true, + }, + { + name: "non-docker driver always chowns (stream mounts)", + goos: "darwin", isDockerDriver: false, isPodman: false, want: true, + }, + { + name: "docker desktop on macOS skips chown", + goos: "darwin", isDockerDriver: true, isPodman: false, want: false, + }, + { + name: "docker desktop on windows skips chown", + goos: "windows", isDockerDriver: true, isPodman: false, want: false, + }, + { + // Regression: rootless podman on macOS bind-mounts host files as + // root-owned, so a non-root remote user cannot enter the workspace + // folder without the chown. Previously skipped because podman uses + // the docker driver, producing "fork/exec /usr/bin/bash: + // permission denied" on the in-container chdir. + name: "podman on macOS chowns despite docker driver", + goos: "darwin", isDockerDriver: true, isPodman: true, want: true, + }, + { + name: "podman on windows chowns despite docker driver", + goos: "windows", isDockerDriver: true, isPodman: true, want: true, + }, + { + name: "podman on linux chowns", + goos: "linux", isDockerDriver: true, isPodman: true, want: true, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := shouldChownWorkspace(c.goos, c.isDockerDriver, c.isPodman) + if got != c.want { + t.Errorf("shouldChownWorkspace(%q, %v, %v) = %v, want %v", + c.goos, c.isDockerDriver, c.isPodman, got, c.want) + } + }) + } +} + +func TestRunnerIsPodmanRuntime(t *testing.T) { + cases := []struct { + name string + runtime string + want bool + }{ + {name: "podman", runtime: "podman", want: true}, + {name: "podman mixed case", runtime: "Podman", want: true}, + {name: "docker", runtime: "docker", want: false}, + {name: "empty defaults to non-podman", runtime: "", want: false}, + {name: "nerdctl", runtime: "nerdctl", want: false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := &runner{ + WorkspaceConfig: &provider2.AgentWorkspaceInfo{ + Agent: provider2.ProviderAgentConfig{ + Docker: provider2.ProviderDockerDriverConfig{Runtime: c.runtime}, + }, + }, + } + if got := r.isPodmanRuntime(); got != c.want { + t.Errorf("isPodmanRuntime() with runtime=%q = %v, want %v", c.runtime, got, c.want) + } + }) + } +} + func TestResolvePullFromInsideContainer(t *testing.T) { cases := []struct { name string From 11acd4e16e56cf95024eae894fcb0804d72e0433 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 24 Jun 2026 19:44:20 -0500 Subject: [PATCH 2/4] fix(lint): extract goos constants and use docker runtime constants Satisfy goconst: replace repeated "linux"/"darwin"/"windows" string literals with goosLinux/goosDarwin/goosWindows constants, and use the existing docker.Runtime* constants in the runtime table test. --- pkg/devcontainer/run.go | 2 +- pkg/devcontainer/setup.go | 6 +++++- pkg/devcontainer/setup_test.go | 21 +++++++++++---------- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/pkg/devcontainer/run.go b/pkg/devcontainer/run.go index 2f9c56fa6..1ace5901e 100644 --- a/pkg/devcontainer/run.go +++ b/pkg/devcontainer/run.go @@ -350,7 +350,7 @@ func mountSetConsistency(mount, value string) string { } func needsDefaultConsistency() bool { - return runtime.GOOS != "linux" + return runtime.GOOS != goosLinux } func getWorkspace( diff --git a/pkg/devcontainer/setup.go b/pkg/devcontainer/setup.go index bdf6322d6..babb6ae26 100644 --- a/pkg/devcontainer/setup.go +++ b/pkg/devcontainer/setup.go @@ -32,6 +32,10 @@ const ( stringTrue = "true" stringFalse = "false" containerRootUser = "root" + + goosLinux = "linux" + goosDarwin = "darwin" + goosWindows = "windows" ) // resolvePullFromInsideContainer: explicit override > crane+git > "". @@ -353,7 +357,7 @@ func (r *runner) addChownFlag(args *[]string, isDockerDriver bool) { // #1879). The agent only chowns when a non-root remote user is resolved, so // root-user containers are unaffected. func shouldChownWorkspace(goos string, isDockerDriver, isPodman bool) bool { - return goos == "linux" || !isDockerDriver || isPodman + return goos == goosLinux || !isDockerDriver || isPodman } // isPodmanRuntime reports whether the workspace's docker driver is backed by diff --git a/pkg/devcontainer/setup_test.go b/pkg/devcontainer/setup_test.go index 9ddbc1010..cb8966979 100644 --- a/pkg/devcontainer/setup_test.go +++ b/pkg/devcontainer/setup_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/docker" provider2 "github.com/devsy-org/devsy/pkg/provider" "github.com/devsy-org/devsy/pkg/types" ) @@ -20,19 +21,19 @@ func TestShouldChownWorkspace(t *testing.T) { }{ { name: "linux docker host always chowns", - goos: "linux", isDockerDriver: true, isPodman: false, want: true, + goos: goosLinux, isDockerDriver: true, isPodman: false, want: true, }, { name: "non-docker driver always chowns (stream mounts)", - goos: "darwin", isDockerDriver: false, isPodman: false, want: true, + goos: goosDarwin, isDockerDriver: false, isPodman: false, want: true, }, { name: "docker desktop on macOS skips chown", - goos: "darwin", isDockerDriver: true, isPodman: false, want: false, + goos: goosDarwin, isDockerDriver: true, isPodman: false, want: false, }, { name: "docker desktop on windows skips chown", - goos: "windows", isDockerDriver: true, isPodman: false, want: false, + goos: goosWindows, isDockerDriver: true, isPodman: false, want: false, }, { // Regression: rootless podman on macOS bind-mounts host files as @@ -41,15 +42,15 @@ func TestShouldChownWorkspace(t *testing.T) { // the docker driver, producing "fork/exec /usr/bin/bash: // permission denied" on the in-container chdir. name: "podman on macOS chowns despite docker driver", - goos: "darwin", isDockerDriver: true, isPodman: true, want: true, + goos: goosDarwin, isDockerDriver: true, isPodman: true, want: true, }, { name: "podman on windows chowns despite docker driver", - goos: "windows", isDockerDriver: true, isPodman: true, want: true, + goos: goosWindows, isDockerDriver: true, isPodman: true, want: true, }, { name: "podman on linux chowns", - goos: "linux", isDockerDriver: true, isPodman: true, want: true, + goos: goosLinux, isDockerDriver: true, isPodman: true, want: true, }, } for _, c := range cases { @@ -69,11 +70,11 @@ func TestRunnerIsPodmanRuntime(t *testing.T) { runtime string want bool }{ - {name: "podman", runtime: "podman", want: true}, + {name: "podman", runtime: string(docker.RuntimePodman), want: true}, {name: "podman mixed case", runtime: "Podman", want: true}, - {name: "docker", runtime: "docker", want: false}, + {name: "docker runtime", runtime: string(docker.RuntimeDocker), want: false}, {name: "empty defaults to non-podman", runtime: "", want: false}, - {name: "nerdctl", runtime: "nerdctl", want: false}, + {name: "nerdctl", runtime: string(docker.RuntimeNerdctl), want: false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { From 9a84df588885d80f456acc49cf1f5ed2687136bb Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 25 Jun 2026 01:38:17 -0500 Subject: [PATCH 3/4] docs: trim verbose comments on chown fix --- e2e/tests/up/provider_podman.go | 23 ++++++----------- .../testdata/docker-nonroot-user/Dockerfile | 5 +--- pkg/devcontainer/setup.go | 25 +++++++------------ pkg/devcontainer/setup_test.go | 7 ++---- 4 files changed, 19 insertions(+), 41 deletions(-) diff --git a/e2e/tests/up/provider_podman.go b/e2e/tests/up/provider_podman.go index 27c1ac180..b38c7c37f 100644 --- a/e2e/tests/up/provider_podman.go +++ b/e2e/tests/up/provider_podman.go @@ -48,13 +48,10 @@ var _ = ginkgo.Describe( ginkgo.SpecTimeout(framework.TimeoutShort()), ) - // Regression: rootless podman bind-mounts the host workspace - // folder as root-owned inside the container. A non-root - // remoteUser could not chdir into it, so the in-container SSH - // server's `fork/exec /usr/bin/bash` failed with "permission - // denied" and `devsy ssh` (and VS Code Remote-SSH) broke. The - // agent must chown the workspace to the remote user even though - // podman runs through the docker driver. + // Regression: rootless podman bind-mounts the workspace folder + // root-owned, so a non-root remoteUser couldn't chdir into it + // and the in-container SSH server failed with "fork/exec + // /usr/bin/bash: permission denied". ginkgo.It( "should ssh into a workspace with a non-root remoteUser", func(ctx context.Context) { @@ -68,19 +65,13 @@ var _ = ginkgo.Describe( err = f.DevsyUp(ctx, tempDir) framework.ExpectNoError(err) - // A non-PTY command exec is exactly the path that failed: - // it runs `bash -c ` with the workspace folder as the - // working directory, as the non-root remote user. whoami, err := f.DevsySSH(ctx, tempDir, "whoami") framework.ExpectNoError(err) gomega.Expect(strings.TrimSpace(whoami)).To(gomega.Equal("devsyuser")) - // The workspace folder must be owned by the remote user. - // This is the actual fix: without the chown the folder - // stays root-owned (rootless podman bind mount), and the - // remote user cannot enter it. Asserting ownership rather - // than mere chdir success avoids a false pass when the - // bind source happens to be world-searchable. + // Assert ownership, not just chdir success: a + // world-searchable bind source would let chdir pass even + // without the chown. owner, err := f.DevsySSH(ctx, tempDir, `stat -c %U "$PWD"`) framework.ExpectNoError(err) gomega.Expect(strings.TrimSpace(owner)).To(gomega.Equal("devsyuser"), diff --git a/e2e/tests/up/testdata/docker-nonroot-user/Dockerfile b/e2e/tests/up/testdata/docker-nonroot-user/Dockerfile index a93682ddc..3b0568e4e 100644 --- a/e2e/tests/up/testdata/docker-nonroot-user/Dockerfile +++ b/e2e/tests/up/testdata/docker-nonroot-user/Dockerfile @@ -1,7 +1,4 @@ FROM ghcr.io/devsy-org/test-images/base:ubuntu -# Create a non-root user. This is the configuration that exposes the podman -# bind-mount ownership bug: rootless podman surfaces the host workspace bind -# mount as root-owned inside the container, so a non-root remote user cannot -# chdir into the workspace folder unless the agent chowns it during setup. +# Non-root user, used as remoteUser to exercise the podman workspace-chown path. RUN useradd --create-home --shell /bin/bash devsyuser diff --git a/pkg/devcontainer/setup.go b/pkg/devcontainer/setup.go index babb6ae26..d241d492e 100644 --- a/pkg/devcontainer/setup.go +++ b/pkg/devcontainer/setup.go @@ -341,28 +341,21 @@ func (r *runner) addChownFlag(args *[]string, isDockerDriver bool) { } } -// shouldChownWorkspace decides whether the agent should chown the workspace +// shouldChownWorkspace reports whether the agent should chown the workspace // folder to the remote user during setup. // -// Docker Desktop's macOS/Windows file sharing already presents bind mounts -// owned by the container user, so a chown is unnecessary there; the flag is -// therefore only added on Linux hosts for the docker driver. Podman is the -// exception: even though it runs through the docker driver, its `podman -// machine` bind mounts surface host files as root-owned inside the container, -// so a non-root remote user cannot enter the workspace folder unless we chown -// it. Request the chown for podman regardless of host OS. -// -// Note: the chown writes through the bind mount to the host inode, so it also -// changes ownership of the host-side workspace contents (cf. loft-sh/devpod -// #1879). The agent only chowns when a non-root remote user is resolved, so -// root-user containers are unaffected. +// Docker Desktop presents macOS/Windows bind mounts already owned by the +// container user, so the chown is only needed on Linux there. Podman is the +// exception: its `podman machine` bind mounts surface host files as root-owned +// inside the container, so a non-root remote user can't enter the workspace +// folder without it — hence chown for podman on any host OS. The chown writes +// through to the host inode (cf. loft-sh/devpod#1879). func shouldChownWorkspace(goos string, isDockerDriver, isPodman bool) bool { return goos == goosLinux || !isDockerDriver || isPodman } -// isPodmanRuntime reports whether the workspace's docker driver is backed by -// the Podman runtime, as configured by the built-in podman provider -// (agent.docker.runtime: podman). +// isPodmanRuntime reports whether the docker driver is backed by the Podman +// runtime (agent.docker.runtime: podman). func (r *runner) isPodmanRuntime() bool { return strings.EqualFold(r.WorkspaceConfig.Agent.Docker.Runtime, string(docker.RuntimePodman)) } diff --git a/pkg/devcontainer/setup_test.go b/pkg/devcontainer/setup_test.go index cb8966979..be9821a25 100644 --- a/pkg/devcontainer/setup_test.go +++ b/pkg/devcontainer/setup_test.go @@ -36,11 +36,8 @@ func TestShouldChownWorkspace(t *testing.T) { goos: goosWindows, isDockerDriver: true, isPodman: false, want: false, }, { - // Regression: rootless podman on macOS bind-mounts host files as - // root-owned, so a non-root remote user cannot enter the workspace - // folder without the chown. Previously skipped because podman uses - // the docker driver, producing "fork/exec /usr/bin/bash: - // permission denied" on the in-container chdir. + // Regression: previously skipped because podman uses the docker + // driver, breaking non-root remoteUser on macOS/Windows. name: "podman on macOS chowns despite docker driver", goos: goosDarwin, isDockerDriver: true, isPodman: true, want: true, }, From 70920bf2ec1f012028fd8335835cfc1d4d28ed1b Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 25 Jun 2026 01:39:56 -0500 Subject: [PATCH 4/4] docs: trim shouldChownWorkspace comment, drop external ref --- pkg/devcontainer/setup.go | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/pkg/devcontainer/setup.go b/pkg/devcontainer/setup.go index d241d492e..974a097a0 100644 --- a/pkg/devcontainer/setup.go +++ b/pkg/devcontainer/setup.go @@ -342,14 +342,9 @@ func (r *runner) addChownFlag(args *[]string, isDockerDriver bool) { } // shouldChownWorkspace reports whether the agent should chown the workspace -// folder to the remote user during setup. -// -// Docker Desktop presents macOS/Windows bind mounts already owned by the -// container user, so the chown is only needed on Linux there. Podman is the -// exception: its `podman machine` bind mounts surface host files as root-owned -// inside the container, so a non-root remote user can't enter the workspace -// folder without it — hence chown for podman on any host OS. The chown writes -// through to the host inode (cf. loft-sh/devpod#1879). +// folder to the remote user during setup. Podman needs it on any host OS: +// its `podman machine` bind mounts are root-owned inside the container, so a +// non-root remote user can't enter the workspace folder otherwise. func shouldChownWorkspace(goos string, isDockerDriver, isPodman bool) bool { return goos == goosLinux || !isDockerDriver || isPodman }