Skip to content

Commit 3fb5d0b

Browse files
authored
fix(agent/agentcontainers): use correct env for execer commands (#18508)
1 parent 7c40f86 commit 3fb5d0b

File tree

7 files changed

+251
-35
lines changed

7 files changed

+251
-35
lines changed

agent/agentcontainers/api.go

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"cdr.dev/slog"
2525
"github.com/coder/coder/v2/agent/agentcontainers/watcher"
2626
"github.com/coder/coder/v2/agent/agentexec"
27+
"github.com/coder/coder/v2/agent/usershell"
2728
"github.com/coder/coder/v2/coderd/httpapi"
2829
"github.com/coder/coder/v2/codersdk"
2930
"github.com/coder/coder/v2/codersdk/agentsdk"
@@ -57,6 +58,7 @@ type API struct {
5758
logger slog.Logger
5859
watcher watcher.Watcher
5960
execer agentexec.Execer
61+
commandEnv CommandEnv
6062
ccli ContainerCLI
6163
containerLabelIncludeFilter map[string]string // Labels to filter containers by.
6264
dccli DevcontainerCLI
@@ -109,6 +111,29 @@ func WithExecer(execer agentexec.Execer) Option {
109111
}
110112
}
111113

114+
// WithCommandEnv sets the CommandEnv implementation to use.
115+
func WithCommandEnv(ce CommandEnv) Option {
116+
return func(api *API) {
117+
api.commandEnv = func(ei usershell.EnvInfoer, preEnv []string) (string, string, []string, error) {
118+
shell, dir, env, err := ce(ei, preEnv)
119+
if err != nil {
120+
return shell, dir, env, err
121+
}
122+
env = slices.DeleteFunc(env, func(s string) bool {
123+
// Ensure we filter out environment variables that come
124+
// from the parent agent and are incorrect or not
125+
// relevant for the devcontainer.
126+
return strings.HasPrefix(s, "CODER_WORKSPACE_AGENT_NAME=") ||
127+
strings.HasPrefix(s, "CODER_WORKSPACE_AGENT_URL=") ||
128+
strings.HasPrefix(s, "CODER_AGENT_TOKEN=") ||
129+
strings.HasPrefix(s, "CODER_AGENT_AUTH=") ||
130+
strings.HasPrefix(s, "CODER_AGENT_DEVCONTAINERS_ENABLE=")
131+
})
132+
return shell, dir, env, nil
133+
}
134+
}
135+
}
136+
112137
// WithContainerCLI sets the agentcontainers.ContainerCLI implementation
113138
// to use. The default implementation uses the Docker CLI.
114139
func WithContainerCLI(ccli ContainerCLI) Option {
@@ -151,7 +176,7 @@ func WithSubAgentURL(url string) Option {
151176
}
152177
}
153178

154-
// WithSubAgent sets the environment variables for the sub-agent.
179+
// WithSubAgentEnv sets the environment variables for the sub-agent.
155180
func WithSubAgentEnv(env ...string) Option {
156181
return func(api *API) {
157182
api.subAgentEnv = env
@@ -259,6 +284,13 @@ func NewAPI(logger slog.Logger, options ...Option) *API {
259284
for _, opt := range options {
260285
opt(api)
261286
}
287+
if api.commandEnv != nil {
288+
api.execer = newCommandEnvExecer(
289+
api.logger,
290+
api.commandEnv,
291+
api.execer,
292+
)
293+
}
262294
if api.ccli == nil {
263295
api.ccli = NewDockerCLI(api.execer)
264296
}

agent/agentcontainers/api_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net/http"
88
"net/http/httptest"
99
"os"
10+
"os/exec"
1011
"runtime"
1112
"strings"
1213
"sync"
@@ -26,7 +27,9 @@ import (
2627
"github.com/coder/coder/v2/agent/agentcontainers"
2728
"github.com/coder/coder/v2/agent/agentcontainers/acmock"
2829
"github.com/coder/coder/v2/agent/agentcontainers/watcher"
30+
"github.com/coder/coder/v2/agent/usershell"
2931
"github.com/coder/coder/v2/codersdk"
32+
"github.com/coder/coder/v2/pty"
3033
"github.com/coder/coder/v2/testutil"
3134
"github.com/coder/quartz"
3235
)
@@ -291,6 +294,38 @@ func (m *fakeSubAgentClient) Delete(ctx context.Context, id uuid.UUID) error {
291294
return nil
292295
}
293296

297+
// fakeExecer implements agentexec.Execer for testing and tracks execution details.
298+
type fakeExecer struct {
299+
commands [][]string
300+
createdCommands []*exec.Cmd
301+
}
302+
303+
func (f *fakeExecer) CommandContext(ctx context.Context, cmd string, args ...string) *exec.Cmd {
304+
f.commands = append(f.commands, append([]string{cmd}, args...))
305+
// Create a command that returns empty JSON for docker commands.
306+
c := exec.CommandContext(ctx, "echo", "[]")
307+
f.createdCommands = append(f.createdCommands, c)
308+
return c
309+
}
310+
311+
func (f *fakeExecer) PTYCommandContext(ctx context.Context, cmd string, args ...string) *pty.Cmd {
312+
f.commands = append(f.commands, append([]string{cmd}, args...))
313+
return &pty.Cmd{
314+
Context: ctx,
315+
Path: cmd,
316+
Args: append([]string{cmd}, args...),
317+
Env: []string{},
318+
Dir: "",
319+
}
320+
}
321+
322+
func (f *fakeExecer) getLastCommand() *exec.Cmd {
323+
if len(f.createdCommands) == 0 {
324+
return nil
325+
}
326+
return f.createdCommands[len(f.createdCommands)-1]
327+
}
328+
294329
func TestAPI(t *testing.T) {
295330
t.Parallel()
296331

@@ -1970,6 +2005,57 @@ func TestAPI(t *testing.T) {
19702005
// Then: We expected it to succeed
19712006
require.Len(t, fSAC.created, 1)
19722007
})
2008+
2009+
t.Run("CommandEnv", func(t *testing.T) {
2010+
t.Parallel()
2011+
2012+
ctx := testutil.Context(t, testutil.WaitShort)
2013+
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
2014+
2015+
// Create fake execer to track execution details.
2016+
fakeExec := &fakeExecer{}
2017+
2018+
// Custom CommandEnv that returns specific values.
2019+
testShell := "/bin/custom-shell"
2020+
testDir := t.TempDir()
2021+
testEnv := []string{"CUSTOM_VAR=test_value", "PATH=/custom/path"}
2022+
2023+
commandEnv := func(ei usershell.EnvInfoer, addEnv []string) (shell, dir string, env []string, err error) {
2024+
return testShell, testDir, testEnv, nil
2025+
}
2026+
2027+
mClock := quartz.NewMock(t) // Stop time.
2028+
2029+
// Create API with CommandEnv.
2030+
api := agentcontainers.NewAPI(logger,
2031+
agentcontainers.WithClock(mClock),
2032+
agentcontainers.WithExecer(fakeExec),
2033+
agentcontainers.WithCommandEnv(commandEnv),
2034+
)
2035+
defer api.Close()
2036+
2037+
// Call RefreshContainers directly to trigger CommandEnv usage.
2038+
_ = api.RefreshContainers(ctx) // Ignore error since docker commands will fail.
2039+
2040+
// Verify commands were executed through the custom shell and environment.
2041+
require.NotEmpty(t, fakeExec.commands, "commands should be executed")
2042+
2043+
// Want: /bin/custom-shell -c "docker ps --all --quiet --no-trunc"
2044+
require.Equal(t, testShell, fakeExec.commands[0][0], "custom shell should be used")
2045+
if runtime.GOOS == "windows" {
2046+
require.Equal(t, "/c", fakeExec.commands[0][1], "shell should be called with /c on Windows")
2047+
} else {
2048+
require.Equal(t, "-c", fakeExec.commands[0][1], "shell should be called with -c")
2049+
}
2050+
require.Len(t, fakeExec.commands[0], 3, "command should have 3 arguments")
2051+
require.GreaterOrEqual(t, strings.Count(fakeExec.commands[0][2], " "), 2, "command/script should have multiple arguments")
2052+
2053+
// Verify the environment was set on the command.
2054+
lastCmd := fakeExec.getLastCommand()
2055+
require.NotNil(t, lastCmd, "command should be created")
2056+
require.Equal(t, testDir, lastCmd.Dir, "custom directory should be used")
2057+
require.Equal(t, testEnv, lastCmd.Env, "custom environment should be used")
2058+
})
19732059
}
19742060

19752061
// mustFindDevcontainerByPath returns the devcontainer with the given workspace

agent/agentcontainers/devcontainercli.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"encoding/json"
88
"errors"
99
"io"
10-
"os"
1110

1211
"golang.org/x/xerrors"
1312

@@ -280,7 +279,6 @@ func (d *devcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, confi
280279
}
281280

282281
c := d.execer.CommandContext(ctx, "devcontainer", args...)
283-
c.Env = append(c.Env, "PATH="+os.Getenv("PATH"))
284282
c.Env = append(c.Env, env...)
285283

286284
var stdoutBuf bytes.Buffer

agent/agentcontainers/execer.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package agentcontainers
2+
3+
import (
4+
"context"
5+
"os/exec"
6+
"runtime"
7+
8+
"github.com/kballard/go-shellquote"
9+
10+
"cdr.dev/slog"
11+
"github.com/coder/coder/v2/agent/agentexec"
12+
"github.com/coder/coder/v2/agent/usershell"
13+
"github.com/coder/coder/v2/pty"
14+
)
15+
16+
// CommandEnv is a function that returns the shell, working directory,
17+
// and environment variables to use when executing a command. It takes
18+
// an EnvInfoer and a pre-existing environment slice as arguments.
19+
// This signature matches agentssh.Server.CommandEnv.
20+
type CommandEnv func(ei usershell.EnvInfoer, addEnv []string) (shell, dir string, env []string, err error)
21+
22+
// commandEnvExecer is an agentexec.Execer that uses a CommandEnv to
23+
// determine the shell, working directory, and environment variables
24+
// for commands. It wraps another agentexec.Execer to provide the
25+
// necessary context.
26+
type commandEnvExecer struct {
27+
logger slog.Logger
28+
commandEnv CommandEnv
29+
execer agentexec.Execer
30+
}
31+
32+
func newCommandEnvExecer(
33+
logger slog.Logger,
34+
commandEnv CommandEnv,
35+
execer agentexec.Execer,
36+
) *commandEnvExecer {
37+
return &commandEnvExecer{
38+
logger: logger,
39+
commandEnv: commandEnv,
40+
execer: execer,
41+
}
42+
}
43+
44+
// Ensure commandEnvExecer implements agentexec.Execer.
45+
var _ agentexec.Execer = (*commandEnvExecer)(nil)
46+
47+
func (e *commandEnvExecer) prepare(ctx context.Context, inName string, inArgs ...string) (name string, args []string, dir string, env []string) {
48+
shell, dir, env, err := e.commandEnv(nil, nil)
49+
if err != nil {
50+
e.logger.Error(ctx, "get command environment failed", slog.Error(err))
51+
return inName, inArgs, "", nil
52+
}
53+
54+
caller := "-c"
55+
if runtime.GOOS == "windows" {
56+
caller = "/c"
57+
}
58+
name = shell
59+
args = []string{caller, shellquote.Join(append([]string{inName}, inArgs...)...)}
60+
return name, args, dir, env
61+
}
62+
63+
func (e *commandEnvExecer) CommandContext(ctx context.Context, cmd string, args ...string) *exec.Cmd {
64+
name, args, dir, env := e.prepare(ctx, cmd, args...)
65+
c := e.execer.CommandContext(ctx, name, args...)
66+
c.Dir = dir
67+
c.Env = env
68+
return c
69+
}
70+
71+
func (e *commandEnvExecer) PTYCommandContext(ctx context.Context, cmd string, args ...string) *pty.Cmd {
72+
name, args, dir, env := e.prepare(ctx, cmd, args...)
73+
c := e.execer.PTYCommandContext(ctx, name, args...)
74+
c.Dir = dir
75+
c.Env = env
76+
return c
77+
}

agent/agentssh/agentssh.go

Lines changed: 47 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -816,6 +816,49 @@ func (s *Server) sftpHandler(logger slog.Logger, session ssh.Session) error {
816816
return xerrors.Errorf("sftp server closed with error: %w", err)
817817
}
818818

819+
func (s *Server) CommandEnv(ei usershell.EnvInfoer, addEnv []string) (shell, dir string, env []string, err error) {
820+
if ei == nil {
821+
ei = &usershell.SystemEnvInfo{}
822+
}
823+
824+
currentUser, err := ei.User()
825+
if err != nil {
826+
return "", "", nil, xerrors.Errorf("get current user: %w", err)
827+
}
828+
username := currentUser.Username
829+
830+
shell, err = ei.Shell(username)
831+
if err != nil {
832+
return "", "", nil, xerrors.Errorf("get user shell: %w", err)
833+
}
834+
835+
dir = s.config.WorkingDirectory()
836+
837+
// If the metadata directory doesn't exist, we run the command
838+
// in the users home directory.
839+
_, err = os.Stat(dir)
840+
if dir == "" || err != nil {
841+
// Default to user home if a directory is not set.
842+
homedir, err := ei.HomeDir()
843+
if err != nil {
844+
return "", "", nil, xerrors.Errorf("get home dir: %w", err)
845+
}
846+
dir = homedir
847+
}
848+
env = append(ei.Environ(), addEnv...)
849+
// Set login variables (see `man login`).
850+
env = append(env, fmt.Sprintf("USER=%s", username))
851+
env = append(env, fmt.Sprintf("LOGNAME=%s", username))
852+
env = append(env, fmt.Sprintf("SHELL=%s", shell))
853+
854+
env, err = s.config.UpdateEnv(env)
855+
if err != nil {
856+
return "", "", nil, xerrors.Errorf("apply env: %w", err)
857+
}
858+
859+
return shell, dir, env, nil
860+
}
861+
819862
// CreateCommand processes raw command input with OpenSSH-like behavior.
820863
// If the script provided is empty, it will default to the users shell.
821864
// This injects environment variables specified by the user at launch too.
@@ -827,15 +870,10 @@ func (s *Server) CreateCommand(ctx context.Context, script string, env []string,
827870
if ei == nil {
828871
ei = &usershell.SystemEnvInfo{}
829872
}
830-
currentUser, err := ei.User()
831-
if err != nil {
832-
return nil, xerrors.Errorf("get current user: %w", err)
833-
}
834-
username := currentUser.Username
835873

836-
shell, err := ei.Shell(username)
874+
shell, dir, env, err := s.CommandEnv(ei, env)
837875
if err != nil {
838-
return nil, xerrors.Errorf("get user shell: %w", err)
876+
return nil, xerrors.Errorf("prepare command env: %w", err)
839877
}
840878

841879
// OpenSSH executes all commands with the users current shell.
@@ -893,24 +931,8 @@ func (s *Server) CreateCommand(ctx context.Context, script string, env []string,
893931
)
894932
}
895933
cmd := s.Execer.PTYCommandContext(ctx, modifiedName, modifiedArgs...)
896-
cmd.Dir = s.config.WorkingDirectory()
897-
898-
// If the metadata directory doesn't exist, we run the command
899-
// in the users home directory.
900-
_, err = os.Stat(cmd.Dir)
901-
if cmd.Dir == "" || err != nil {
902-
// Default to user home if a directory is not set.
903-
homedir, err := ei.HomeDir()
904-
if err != nil {
905-
return nil, xerrors.Errorf("get home dir: %w", err)
906-
}
907-
cmd.Dir = homedir
908-
}
909-
cmd.Env = append(ei.Environ(), env...)
910-
// Set login variables (see `man login`).
911-
cmd.Env = append(cmd.Env, fmt.Sprintf("USER=%s", username))
912-
cmd.Env = append(cmd.Env, fmt.Sprintf("LOGNAME=%s", username))
913-
cmd.Env = append(cmd.Env, fmt.Sprintf("SHELL=%s", shell))
934+
cmd.Dir = dir
935+
cmd.Env = env
914936

915937
// Set SSH connection environment variables (these are also set by OpenSSH
916938
// and thus expected to be present by SSH clients). Since the agent does
@@ -921,11 +943,6 @@ func (s *Server) CreateCommand(ctx context.Context, script string, env []string,
921943
cmd.Env = append(cmd.Env, fmt.Sprintf("SSH_CLIENT=%s %s %s", srcAddr, srcPort, dstPort))
922944
cmd.Env = append(cmd.Env, fmt.Sprintf("SSH_CONNECTION=%s %s %s %s", srcAddr, srcPort, dstAddr, dstPort))
923945

924-
cmd.Env, err = s.config.UpdateEnv(cmd.Env)
925-
if err != nil {
926-
return nil, xerrors.Errorf("apply env: %w", err)
927-
}
928-
929946
return cmd, nil
930947
}
931948

agent/api.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ func (a *agent) apiHandler(aAPI proto.DRPCAgentClient26) (http.Handler, func() e
4343
if a.experimentalDevcontainersEnabled {
4444
containerAPIOpts := []agentcontainers.Option{
4545
agentcontainers.WithExecer(a.execer),
46+
agentcontainers.WithCommandEnv(a.sshServer.CommandEnv),
4647
agentcontainers.WithScriptLogger(func(logSourceID uuid.UUID) agentcontainers.ScriptLogger {
4748
return a.logSender.GetScriptLogger(logSourceID)
4849
}),

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy