Skip to content

Commit c9535de

Browse files
committed
fix(agent/agentcontainers): use correct env execer commands
Fixes coder/internal#707
1 parent 0a12ec5 commit c9535de

File tree

6 files changed

+235
-33
lines changed

6 files changed

+235
-33
lines changed

agent/agentcontainers/api.go

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"cdr.dev/slog"
2424
"github.com/coder/coder/v2/agent/agentcontainers/watcher"
2525
"github.com/coder/coder/v2/agent/agentexec"
26+
"github.com/coder/coder/v2/agent/usershell"
2627
"github.com/coder/coder/v2/coderd/httpapi"
2728
"github.com/coder/coder/v2/codersdk"
2829
"github.com/coder/coder/v2/codersdk/agentsdk"
@@ -54,6 +55,7 @@ type API struct {
5455
logger slog.Logger
5556
watcher watcher.Watcher
5657
execer agentexec.Execer
58+
commandEnv CommandEnv
5759
ccli ContainerCLI
5860
containerLabelIncludeFilter map[string]string // Labels to filter containers by.
5961
dccli DevcontainerCLI
@@ -106,6 +108,29 @@ func WithExecer(execer agentexec.Execer) Option {
106108
}
107109
}
108110

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

151-
// WithSubAgent sets the environment variables for the sub-agent.
176+
// WithSubAgentEnv sets the environment variables for the sub-agent.
152177
func WithSubAgentEnv(env ...string) Option {
153178
return func(api *API) {
154179
api.subAgentEnv = env
@@ -256,6 +281,13 @@ func NewAPI(logger slog.Logger, options ...Option) *API {
256281
for _, opt := range options {
257282
opt(api)
258283
}
284+
if api.commandEnv != nil {
285+
api.execer = newCommandEnvExecer(
286+
api.logger,
287+
api.commandEnv,
288+
api.execer,
289+
)
290+
}
259291
if api.ccli == nil {
260292
api.ccli = NewDockerCLI(api.execer)
261293
}

agent/agentcontainers/api_test.go

Lines changed: 77 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,48 @@ 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+
// Create API with CommandEnv.
2028+
api := agentcontainers.NewAPI(logger,
2029+
agentcontainers.WithExecer(fakeExec),
2030+
agentcontainers.WithCommandEnv(commandEnv),
2031+
)
2032+
defer api.Close()
2033+
2034+
// Call RefreshContainers directly to trigger CommandEnv usage.
2035+
_ = api.RefreshContainers(ctx) // Ignore error since docker commands will fail.
2036+
2037+
// Verify commands were executed through the custom shell and environment.
2038+
require.NotEmpty(t, fakeExec.commands, "commands should be executed")
2039+
2040+
// The first command should be executed through the custom shell with -c.
2041+
require.Equal(t, testShell, fakeExec.commands[0][0], "custom shell should be used")
2042+
require.Equal(t, "-c", fakeExec.commands[0][1], "shell should be called with -c")
2043+
2044+
// Verify the environment was set on the command.
2045+
lastCmd := fakeExec.getLastCommand()
2046+
require.NotNil(t, lastCmd, "command should be created")
2047+
require.Equal(t, testDir, lastCmd.Dir, "custom directory should be used")
2048+
require.Equal(t, testEnv, lastCmd.Env, "custom environment should be used")
2049+
})
19732050
}
19742051

19752052
// 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}, args...)...)}
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