From 4622ec2a238fdd6e87b01654ae81ae9be8b8ac3b Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 26 Apr 2026 16:05:57 -0500 Subject: [PATCH 1/9] feat(cli): add exec command for container command execution Adds a new `exec` command that executes commands inside a running workspace container via docker exec. Supports --workspace-folder, --config, and --remote-env flags with positional args after --. --- cmd/exec.go | 139 ++++++++++++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 1 + 2 files changed, 140 insertions(+) create mode 100644 cmd/exec.go diff --git a/cmd/exec.go b/cmd/exec.go new file mode 100644 index 000000000..3654819c9 --- /dev/null +++ b/cmd/exec.go @@ -0,0 +1,139 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" + devcconfig "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/docker" + "github.com/devsy-org/devsy/pkg/log" + workspace2 "github.com/devsy-org/devsy/pkg/workspace" + "github.com/spf13/cobra" +) + +// ExecCmd holds the exec cmd flags. +type ExecCmd struct { + *flags.GlobalFlags + + WorkspaceFolder string + Config string + RemoteEnv []string +} + +// NewExecCmd creates a new exec command. +func NewExecCmd(f *flags.GlobalFlags) *cobra.Command { + cmd := &ExecCmd{GlobalFlags: f} + execCmd := &cobra.Command{ + Use: "exec --workspace-folder -- [args...]", + Short: "Executes a command in a running workspace container", + Args: cobra.MinimumNArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + ctx := cobraCmd.Context() + return cmd.Run(ctx, args) + }, + } + + execCmd.Flags(). + StringVar( + &cmd.WorkspaceFolder, + "workspace-folder", + "", + "Path to the workspace folder", + ) + _ = execCmd.MarkFlagRequired("workspace-folder") + execCmd.Flags(). + StringVar( + &cmd.Config, + "config", + "", + "Path to a specific devcontainer.json", + ) + execCmd.Flags(). + StringSliceVar( + &cmd.RemoteEnv, + "remote-env", + []string{}, + "Environment variables to set in the container (KEY=VALUE format)", + ) + + return execCmd +} + +// Run executes the exec command. +func (cmd *ExecCmd) Run(ctx context.Context, args []string) error { + devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + if err != nil { + return err + } + + client, err := workspace2.Get(ctx, workspace2.GetOptions{ + DevsyConfig: devsyConfig, + Args: []string{cmd.WorkspaceFolder}, + Owner: cmd.Owner, + }) + if err != nil { + return fmt.Errorf("resolve workspace: %w", err) + } + + containerDetails, err := findRunningContainer(ctx, client.Workspace()) + if err != nil { + return err + } + + return cmd.execInContainer(ctx, containerDetails.ID, args) +} + +func findRunningContainer( + ctx context.Context, + workspaceID string, +) (*devcconfig.ContainerDetails, error) { + dockerHelper := &docker.DockerHelper{ + DockerCommand: "docker", + } + + labels := devcconfig.GetDockerLabelForID(workspaceID) + container, err := dockerHelper.FindDevContainer(ctx, labels) + if err != nil { + return nil, fmt.Errorf("find container: %w", err) + } + if container == nil { + return nil, fmt.Errorf( + "no running container found for workspace %q", + workspaceID, + ) + } + + if strings.ToLower(container.State.Status) != "running" { + return nil, fmt.Errorf( + "container %s is not running (status: %s)", + container.ID, + container.State.Status, + ) + } + + return container, nil +} + +func (cmd *ExecCmd) execInContainer( + ctx context.Context, + containerID string, + args []string, +) error { + dockerHelper := &docker.DockerHelper{ + DockerCommand: "docker", + } + + execArgs := []string{"exec", "-i"} + for _, env := range cmd.RemoteEnv { + execArgs = append(execArgs, "-e", env) + } + execArgs = append(execArgs, containerID) + execArgs = append(execArgs, args...) + + log.Debugf("Executing in container: docker %s", strings.Join(execArgs, " ")) + return dockerHelper.Run(ctx, execArgs, os.Stdin, os.Stdout, os.Stderr) +} diff --git a/cmd/root.go b/cmd/root.go index f2fe2336d..1a60acf39 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -129,6 +129,7 @@ func BuildRoot() *cobra.Command { rootCmd.AddCommand(NewTroubleshootCmd(globalFlags)) rootCmd.AddCommand(NewPingCmd(globalFlags)) rootCmd.AddCommand(NewReadConfigurationCmd(globalFlags)) + rootCmd.AddCommand(NewExecCmd(globalFlags)) inheritCommandFlagsFromEnvironment(rootCmd) From b2ccf6e5f7c695b1947fecee7c7571fbcee87883 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 26 Apr 2026 16:23:31 -0500 Subject: [PATCH 2/9] fix(cli): address exec cmd review blockers and add tests Remove unused --config flag, resolve docker command from provider config instead of hardcoding, add remote-env KEY=VALUE validation, add TTY detection for -t flag, and add unit + e2e tests. --- cmd/exec.go | 71 ++++++++++++++---- cmd/exec_test.go | 67 +++++++++++++++++ e2e/e2e_suite_test.go | 1 + e2e/tests/exec/exec.go | 87 +++++++++++++++++++++++ e2e/tests/exec/testdata/devcontainer.json | 4 ++ 5 files changed, 217 insertions(+), 13 deletions(-) create mode 100644 cmd/exec_test.go create mode 100644 e2e/tests/exec/exec.go create mode 100644 e2e/tests/exec/testdata/devcontainer.json diff --git a/cmd/exec.go b/cmd/exec.go index 3654819c9..dc36441a4 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -11,8 +11,10 @@ import ( devcconfig "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/docker" "github.com/devsy-org/devsy/pkg/log" + provider2 "github.com/devsy-org/devsy/pkg/provider" workspace2 "github.com/devsy-org/devsy/pkg/workspace" "github.com/spf13/cobra" + "golang.org/x/term" ) // ExecCmd holds the exec cmd flags. @@ -20,7 +22,6 @@ type ExecCmd struct { *flags.GlobalFlags WorkspaceFolder string - Config string RemoteEnv []string } @@ -45,13 +46,6 @@ func NewExecCmd(f *flags.GlobalFlags) *cobra.Command { "Path to the workspace folder", ) _ = execCmd.MarkFlagRequired("workspace-folder") - execCmd.Flags(). - StringVar( - &cmd.Config, - "config", - "", - "Path to a specific devcontainer.json", - ) execCmd.Flags(). StringSliceVar( &cmd.RemoteEnv, @@ -65,6 +59,10 @@ func NewExecCmd(f *flags.GlobalFlags) *cobra.Command { // Run executes the exec command. func (cmd *ExecCmd) Run(ctx context.Context, args []string) error { + if err := cmd.validateRemoteEnv(); err != nil { + return err + } + devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) if err != nil { return err @@ -79,20 +77,63 @@ func (cmd *ExecCmd) Run(ctx context.Context, args []string) error { return fmt.Errorf("resolve workspace: %w", err) } - containerDetails, err := findRunningContainer(ctx, client.Workspace()) + dockerCommand, err := resolveDockerCommand( + client.WorkspaceConfig(), + ) if err != nil { return err } - return cmd.execInContainer(ctx, containerDetails.ID, args) + containerDetails, err := findRunningContainer( + ctx, dockerCommand, client.Workspace(), + ) + if err != nil { + return err + } + + return cmd.execInContainer(ctx, dockerCommand, containerDetails.ID, args) +} + +func (cmd *ExecCmd) validateRemoteEnv() error { + for _, env := range cmd.RemoteEnv { + parts := strings.SplitN(env, "=", 2) + if len(parts) != 2 || parts[0] == "" { + return fmt.Errorf("invalid remote-env value %q: must be KEY=VALUE format", env) + } + } + return nil +} + +func resolveDockerCommand( + workspace *provider2.Workspace, +) (string, error) { + if workspace == nil || workspace.Context == "" { + return "docker", nil + } + + providerConfig, err := provider2.LoadProviderConfig( + workspace.Context, + workspace.Provider.Name, + ) + if err != nil { + log.Debugf("Failed to load provider config, defaulting to 'docker': %v", err) + return "docker", nil + } + + if providerConfig.Agent.Docker.Path != "" { + return providerConfig.Agent.Docker.Path, nil + } + + return "docker", nil } func findRunningContainer( ctx context.Context, + dockerCommand string, workspaceID string, ) (*devcconfig.ContainerDetails, error) { dockerHelper := &docker.DockerHelper{ - DockerCommand: "docker", + DockerCommand: dockerCommand, } labels := devcconfig.GetDockerLabelForID(workspaceID) @@ -120,20 +161,24 @@ func findRunningContainer( func (cmd *ExecCmd) execInContainer( ctx context.Context, + dockerCommand string, containerID string, args []string, ) error { dockerHelper := &docker.DockerHelper{ - DockerCommand: "docker", + DockerCommand: dockerCommand, } execArgs := []string{"exec", "-i"} + if term.IsTerminal(int(os.Stdin.Fd())) { // #nosec G115 -- fd is always a valid file descriptor + execArgs = append(execArgs, "-t") + } for _, env := range cmd.RemoteEnv { execArgs = append(execArgs, "-e", env) } execArgs = append(execArgs, containerID) execArgs = append(execArgs, args...) - log.Debugf("Executing in container: docker %s", strings.Join(execArgs, " ")) + log.Debugf("Executing in container: %s %s", dockerCommand, strings.Join(execArgs, " ")) return dockerHelper.Run(ctx, execArgs, os.Stdin, os.Stdout, os.Stderr) } diff --git a/cmd/exec_test.go b/cmd/exec_test.go new file mode 100644 index 000000000..1bc2f4e94 --- /dev/null +++ b/cmd/exec_test.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "testing" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateRemoteEnv_Valid(t *testing.T) { + cmd := &ExecCmd{ + GlobalFlags: &flags.GlobalFlags{}, + RemoteEnv: []string{"FOO=bar", "BAZ=qux=extra"}, + } + assert.NoError(t, cmd.validateRemoteEnv()) +} + +func TestValidateRemoteEnv_Empty(t *testing.T) { + cmd := &ExecCmd{ + GlobalFlags: &flags.GlobalFlags{}, + RemoteEnv: []string{}, + } + assert.NoError(t, cmd.validateRemoteEnv()) +} + +func TestValidateRemoteEnv_MissingEquals(t *testing.T) { + cmd := &ExecCmd{ + GlobalFlags: &flags.GlobalFlags{}, + RemoteEnv: []string{"INVALID"}, + } + err := cmd.validateRemoteEnv() + require.Error(t, err) + assert.Contains(t, err.Error(), "must be KEY=VALUE format") +} + +func TestValidateRemoteEnv_EmptyKey(t *testing.T) { + cmd := &ExecCmd{ + GlobalFlags: &flags.GlobalFlags{}, + RemoteEnv: []string{"=value"}, + } + err := cmd.validateRemoteEnv() + require.Error(t, err) + assert.Contains(t, err.Error(), "must be KEY=VALUE format") +} + +func TestNewExecCmd_RequiresWorkspaceFolder(t *testing.T) { + execCmd := NewExecCmd(&flags.GlobalFlags{}) + execCmd.SetArgs([]string{"--", "echo", "hello"}) + err := execCmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "workspace-folder") +} + +func TestNewExecCmd_RequiresArgs(t *testing.T) { + execCmd := NewExecCmd(&flags.GlobalFlags{}) + execCmd.SetArgs([]string{"--workspace-folder", "/tmp/test"}) + err := execCmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "requires at least 1 arg") +} + +func TestResolveDockerCommand_NilWorkspace(t *testing.T) { + result, err := resolveDockerCommand(nil) + assert.NoError(t, err) + assert.Equal(t, "docker", result) +} diff --git a/e2e/e2e_suite_test.go b/e2e/e2e_suite_test.go index db0636b6a..bcf7e5698 100644 --- a/e2e/e2e_suite_test.go +++ b/e2e/e2e_suite_test.go @@ -12,6 +12,7 @@ import ( _ "github.com/devsy-org/devsy/e2e/tests/context" _ "github.com/devsy-org/devsy/e2e/tests/dockerinstall" _ "github.com/devsy-org/devsy/e2e/tests/down" + _ "github.com/devsy-org/devsy/e2e/tests/exec" _ "github.com/devsy-org/devsy/e2e/tests/ide" _ "github.com/devsy-org/devsy/e2e/tests/integration" _ "github.com/devsy-org/devsy/e2e/tests/logs" diff --git a/e2e/tests/exec/exec.go b/e2e/tests/exec/exec.go new file mode 100644 index 000000000..5df7a2159 --- /dev/null +++ b/e2e/tests/exec/exec.go @@ -0,0 +1,87 @@ +package exec + +import ( + "context" + "os" + "strings" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe("devsy exec test suite", ginkgo.Label("exec"), ginkgo.Ordered, func() { + var initialDir string + + ginkgo.BeforeEach(func() { + var err error + initialDir, err = os.Getwd() + framework.ExpectNoError(err) + }) + + ginkgo.It("should exec a command in a running workspace container", + func(ctx context.Context) { + tempDir, err := framework.CopyToTempDir("tests/exec/testdata") + framework.ExpectNoError(err) + + f := framework.NewDefaultFramework(initialDir + "/bin") + _ = f.DevsyProviderAdd(ctx, "docker") + err = f.DevsyProviderUse(ctx, "docker") + framework.ExpectNoError(err) + + ginkgo.DeferCleanup(func(cleanupCtx context.Context) { + _ = f.DevsyWorkspaceDelete(cleanupCtx, tempDir) + framework.CleanupTempDir(initialDir, tempDir) + }) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + "exec", + "--workspace-folder", tempDir, + "--", "echo", "-n", "hello", + }) + framework.ExpectNoError(err) + gomega.Expect(stdout).To(gomega.Equal("hello")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should pass remote-env to the container", + func(ctx context.Context) { + tempDir, err := framework.CopyToTempDir("tests/exec/testdata") + framework.ExpectNoError(err) + + f := framework.NewDefaultFramework(initialDir + "/bin") + _ = f.DevsyProviderAdd(ctx, "docker") + err = f.DevsyProviderUse(ctx, "docker") + framework.ExpectNoError(err) + + ginkgo.DeferCleanup(func(cleanupCtx context.Context) { + _ = f.DevsyWorkspaceDelete(cleanupCtx, tempDir) + framework.CleanupTempDir(initialDir, tempDir) + }) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + "exec", + "--workspace-folder", tempDir, + "--remote-env", "MY_TEST_VAR=test_value", + "--", "sh", "-c", "echo -n $MY_TEST_VAR", + }) + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(stdout)).To(gomega.Equal("test_value")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should fail without --workspace-folder flag", + func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + _, _, err := f.ExecCommandCapture(ctx, []string{ + "exec", + "--", "echo", "hello", + }) + framework.ExpectError(err) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) +}) diff --git a/e2e/tests/exec/testdata/devcontainer.json b/e2e/tests/exec/testdata/devcontainer.json new file mode 100644 index 000000000..1aabfc5f8 --- /dev/null +++ b/e2e/tests/exec/testdata/devcontainer.json @@ -0,0 +1,4 @@ +{ + "name": "Exec Test", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu" +} From 12c778337ad76ae8d9b7c7992519692c1f8d6eda Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 26 Apr 2026 16:44:56 -0500 Subject: [PATCH 3/9] fix(cli): resolve unparam lint in resolveDockerCommand --- cmd/exec.go | 17 ++++++----------- cmd/exec_test.go | 3 +-- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/cmd/exec.go b/cmd/exec.go index dc36441a4..9f0ac9521 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -77,12 +77,7 @@ func (cmd *ExecCmd) Run(ctx context.Context, args []string) error { return fmt.Errorf("resolve workspace: %w", err) } - dockerCommand, err := resolveDockerCommand( - client.WorkspaceConfig(), - ) - if err != nil { - return err - } + dockerCommand := resolveDockerCommand(client.WorkspaceConfig()) containerDetails, err := findRunningContainer( ctx, dockerCommand, client.Workspace(), @@ -106,9 +101,9 @@ func (cmd *ExecCmd) validateRemoteEnv() error { func resolveDockerCommand( workspace *provider2.Workspace, -) (string, error) { +) string { if workspace == nil || workspace.Context == "" { - return "docker", nil + return "docker" } providerConfig, err := provider2.LoadProviderConfig( @@ -117,14 +112,14 @@ func resolveDockerCommand( ) if err != nil { log.Debugf("Failed to load provider config, defaulting to 'docker': %v", err) - return "docker", nil + return "docker" } if providerConfig.Agent.Docker.Path != "" { - return providerConfig.Agent.Docker.Path, nil + return providerConfig.Agent.Docker.Path } - return "docker", nil + return "docker" } func findRunningContainer( diff --git a/cmd/exec_test.go b/cmd/exec_test.go index 1bc2f4e94..d2a26b8ec 100644 --- a/cmd/exec_test.go +++ b/cmd/exec_test.go @@ -61,7 +61,6 @@ func TestNewExecCmd_RequiresArgs(t *testing.T) { } func TestResolveDockerCommand_NilWorkspace(t *testing.T) { - result, err := resolveDockerCommand(nil) - assert.NoError(t, err) + result := resolveDockerCommand(nil) assert.Equal(t, "docker", result) } From 3f5f145ef6a8d4680342e6f9b5e7f4752148e124 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 26 Apr 2026 16:56:35 -0500 Subject: [PATCH 4/9] ci: add exec e2e test matrix entry to pr-ci.yml --- .github/workflows/pr-ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 4da3b781f..4946f701f 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -198,6 +198,12 @@ jobs: install-kind: false requires-secret: false + - label: exec + runner: ubuntu-latest + free-disk-space: false + install-kind: false + requires-secret: false + # Up tests - label: up-workspaces From 7bc0cfb6a1f6625fdc71fe9c0106006d3d6f5f1e Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 26 Apr 2026 19:32:59 -0500 Subject: [PATCH 5/9] fix(e2e): use SetupDockerProvider for exec tests The provider YAML stores docker path as ${DOCKER_PATH}. Without explicit DOCKER_PATH=docker during provider add, resolveDockerCommand returns the literal unexpanded variable, causing container lookup to fail with "executable file not found in $PATH". --- e2e/tests/exec/exec.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/e2e/tests/exec/exec.go b/e2e/tests/exec/exec.go index 5df7a2159..a7d969260 100644 --- a/e2e/tests/exec/exec.go +++ b/e2e/tests/exec/exec.go @@ -24,9 +24,7 @@ var _ = ginkgo.Describe("devsy exec test suite", ginkgo.Label("exec"), ginkgo.Or tempDir, err := framework.CopyToTempDir("tests/exec/testdata") framework.ExpectNoError(err) - f := framework.NewDefaultFramework(initialDir + "/bin") - _ = f.DevsyProviderAdd(ctx, "docker") - err = f.DevsyProviderUse(ctx, "docker") + f, err := framework.SetupDockerProvider(initialDir+"/bin", "docker") framework.ExpectNoError(err) ginkgo.DeferCleanup(func(cleanupCtx context.Context) { @@ -51,9 +49,7 @@ var _ = ginkgo.Describe("devsy exec test suite", ginkgo.Label("exec"), ginkgo.Or tempDir, err := framework.CopyToTempDir("tests/exec/testdata") framework.ExpectNoError(err) - f := framework.NewDefaultFramework(initialDir + "/bin") - _ = f.DevsyProviderAdd(ctx, "docker") - err = f.DevsyProviderUse(ctx, "docker") + f, err := framework.SetupDockerProvider(initialDir+"/bin", "docker") framework.ExpectNoError(err) ginkgo.DeferCleanup(func(cleanupCtx context.Context) { From bf7814d0870a0a837baa9f86cbe90b3a6bd27ad6 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 27 Apr 2026 12:04:13 -0500 Subject: [PATCH 6/9] fix(security): redact env values in exec debug log The debug log in execInContainer printed full KEY=VALUE pairs from RemoteEnv flags, which could leak secrets. Redact values so only key names appear in debug output (e.g., KEY=). --- cmd/exec.go | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/cmd/exec.go b/cmd/exec.go index 9f0ac9521..5729cd75a 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -174,6 +174,24 @@ func (cmd *ExecCmd) execInContainer( execArgs = append(execArgs, containerID) execArgs = append(execArgs, args...) - log.Debugf("Executing in container: %s %s", dockerCommand, strings.Join(execArgs, " ")) + log.Debugf("Executing in container: %s %s", dockerCommand, strings.Join(redactExecArgs(execArgs), " ")) return dockerHelper.Run(ctx, execArgs, os.Stdin, os.Stdout, os.Stderr) } + +func redactExecArgs(args []string) []string { + redacted := make([]string, len(args)) + for i := 0; i < len(args); i++ { + if args[i] == "-e" && i+1 < len(args) { + redacted[i] = args[i] + i++ + if k, _, ok := strings.Cut(args[i], "="); ok { + redacted[i] = k + "=" + } else { + redacted[i] = args[i] + } + } else { + redacted[i] = args[i] + } + } + return redacted +} From 679f6f74e9fe46565785c74462e9b7f8499feb37 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 27 Apr 2026 12:10:42 -0500 Subject: [PATCH 7/9] fix(lint): extract docker constant and break long debug log line Address goconst lint by extracting repeated "docker" string to defaultDockerCommand const. Break long log.Debugf line to satisfy golines max-len=120. --- cmd/exec.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/cmd/exec.go b/cmd/exec.go index 5729cd75a..841cd8f1d 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -17,6 +17,8 @@ import ( "golang.org/x/term" ) +const defaultDockerCommand = "docker" + // ExecCmd holds the exec cmd flags. type ExecCmd struct { *flags.GlobalFlags @@ -103,7 +105,7 @@ func resolveDockerCommand( workspace *provider2.Workspace, ) string { if workspace == nil || workspace.Context == "" { - return "docker" + return defaultDockerCommand } providerConfig, err := provider2.LoadProviderConfig( @@ -112,14 +114,14 @@ func resolveDockerCommand( ) if err != nil { log.Debugf("Failed to load provider config, defaulting to 'docker': %v", err) - return "docker" + return defaultDockerCommand } if providerConfig.Agent.Docker.Path != "" { return providerConfig.Agent.Docker.Path } - return "docker" + return defaultDockerCommand } func findRunningContainer( @@ -174,7 +176,8 @@ func (cmd *ExecCmd) execInContainer( execArgs = append(execArgs, containerID) execArgs = append(execArgs, args...) - log.Debugf("Executing in container: %s %s", dockerCommand, strings.Join(redactExecArgs(execArgs), " ")) + redacted := strings.Join(redactExecArgs(execArgs), " ") + log.Debugf("Executing in container: %s %s", dockerCommand, redacted) return dockerHelper.Run(ctx, execArgs, os.Stdin, os.Stdout, os.Stderr) } From e6429854668ed4ae86644cd87b9f1fe8e07ac120 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 27 Apr 2026 12:47:22 -0500 Subject: [PATCH 8/9] fix(exec): expand env vars in provider docker path resolveDockerCommand returned the literal ${DOCKER_PATH} template string from provider config instead of expanding it. Use os.ExpandEnv so env vars like DOCKER_PATH are resolved on the CLI host side. --- cmd/exec.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/exec.go b/cmd/exec.go index 841cd8f1d..8a77de3ca 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -118,7 +118,7 @@ func resolveDockerCommand( } if providerConfig.Agent.Docker.Path != "" { - return providerConfig.Agent.Docker.Path + return os.ExpandEnv(providerConfig.Agent.Docker.Path) } return defaultDockerCommand From 33972e1546640d88929628f9106625444d6fa800 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 27 Apr 2026 13:10:17 -0500 Subject: [PATCH 9/9] fix(exec): fall back to default docker when expanded path is empty When DOCKER_PATH env var is unset, os.ExpandEnv returns an empty string. Fall back to defaultDockerCommand instead of passing an empty command to exec. --- cmd/exec.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/exec.go b/cmd/exec.go index 8a77de3ca..12a3d44f6 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -118,7 +118,9 @@ func resolveDockerCommand( } if providerConfig.Agent.Docker.Path != "" { - return os.ExpandEnv(providerConfig.Agent.Docker.Path) + if expanded := os.ExpandEnv(providerConfig.Agent.Docker.Path); expanded != "" { + return expanded + } } return defaultDockerCommand