Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[pull] main from coder:main #269

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Jun 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,5 +103,4 @@ Read [cursor rules](.cursorrules).

The frontend is contained in the site folder.

For building Frontend refer to [this document](docs/contributing/frontend.md)
For building Frontend refer to [this document](docs/about/contributing/frontend.md)
46 changes: 43 additions & 3 deletions agent/agentcontainers/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"cdr.dev/slog"
"github.com/coder/coder/v2/agent/agentcontainers/watcher"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/agent/usershell"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
Expand Down Expand Up @@ -57,6 +58,7 @@ type API struct {
logger slog.Logger
watcher watcher.Watcher
execer agentexec.Execer
commandEnv CommandEnv
ccli ContainerCLI
containerLabelIncludeFilter map[string]string // Labels to filter containers by.
dccli DevcontainerCLI
Expand Down Expand Up @@ -109,6 +111,29 @@ func WithExecer(execer agentexec.Execer) Option {
}
}

// WithCommandEnv sets the CommandEnv implementation to use.
func WithCommandEnv(ce CommandEnv) Option {
return func(api *API) {
api.commandEnv = func(ei usershell.EnvInfoer, preEnv []string) (string, string, []string, error) {
shell, dir, env, err := ce(ei, preEnv)
if err != nil {
return shell, dir, env, err
}
env = slices.DeleteFunc(env, func(s string) bool {
// Ensure we filter out environment variables that come
// from the parent agent and are incorrect or not
// relevant for the devcontainer.
return strings.HasPrefix(s, "CODER_WORKSPACE_AGENT_NAME=") ||
strings.HasPrefix(s, "CODER_WORKSPACE_AGENT_URL=") ||
strings.HasPrefix(s, "CODER_AGENT_TOKEN=") ||
strings.HasPrefix(s, "CODER_AGENT_AUTH=") ||
strings.HasPrefix(s, "CODER_AGENT_DEVCONTAINERS_ENABLE=")
})
return shell, dir, env, nil
}
}
}

// WithContainerCLI sets the agentcontainers.ContainerCLI implementation
// to use. The default implementation uses the Docker CLI.
func WithContainerCLI(ccli ContainerCLI) Option {
Expand Down Expand Up @@ -151,7 +176,7 @@ func WithSubAgentURL(url string) Option {
}
}

// WithSubAgent sets the environment variables for the sub-agent.
// WithSubAgentEnv sets the environment variables for the sub-agent.
func WithSubAgentEnv(env ...string) Option {
return func(api *API) {
api.subAgentEnv = env
Expand Down Expand Up @@ -259,6 +284,13 @@ func NewAPI(logger slog.Logger, options ...Option) *API {
for _, opt := range options {
opt(api)
}
if api.commandEnv != nil {
api.execer = newCommandEnvExecer(
api.logger,
api.commandEnv,
api.execer,
)
}
if api.ccli == nil {
api.ccli = NewDockerCLI(api.execer)
}
Expand Down Expand Up @@ -346,7 +378,11 @@ func (api *API) updaterLoop() {
// and anyone looking to interact with the API.
api.logger.Debug(api.ctx, "performing initial containers update")
if err := api.updateContainers(api.ctx); err != nil {
api.logger.Error(api.ctx, "initial containers update failed", slog.Error(err))
if errors.Is(err, context.Canceled) {
api.logger.Warn(api.ctx, "initial containers update canceled", slog.Error(err))
} else {
api.logger.Error(api.ctx, "initial containers update failed", slog.Error(err))
}
} else {
api.logger.Debug(api.ctx, "initial containers update complete")
}
Expand All @@ -367,7 +403,11 @@ func (api *API) updaterLoop() {
case api.updateTrigger <- done:
err := <-done
if err != nil {
api.logger.Error(api.ctx, "updater loop ticker failed", slog.Error(err))
if errors.Is(err, context.Canceled) {
api.logger.Warn(api.ctx, "updater loop ticker canceled", slog.Error(err))
} else {
api.logger.Error(api.ctx, "updater loop ticker failed", slog.Error(err))
}
}
default:
api.logger.Debug(api.ctx, "updater loop ticker skipped, update in progress")
Expand Down
86 changes: 86 additions & 0 deletions agent/agentcontainers/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"os/exec"
"runtime"
"strings"
"sync"
Expand All @@ -26,7 +27,9 @@ import (
"github.com/coder/coder/v2/agent/agentcontainers"
"github.com/coder/coder/v2/agent/agentcontainers/acmock"
"github.com/coder/coder/v2/agent/agentcontainers/watcher"
"github.com/coder/coder/v2/agent/usershell"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
Expand Down Expand Up @@ -291,6 +294,38 @@ func (m *fakeSubAgentClient) Delete(ctx context.Context, id uuid.UUID) error {
return nil
}

// fakeExecer implements agentexec.Execer for testing and tracks execution details.
type fakeExecer struct {
commands [][]string
createdCommands []*exec.Cmd
}

func (f *fakeExecer) CommandContext(ctx context.Context, cmd string, args ...string) *exec.Cmd {
f.commands = append(f.commands, append([]string{cmd}, args...))
// Create a command that returns empty JSON for docker commands.
c := exec.CommandContext(ctx, "echo", "[]")
f.createdCommands = append(f.createdCommands, c)
return c
}

func (f *fakeExecer) PTYCommandContext(ctx context.Context, cmd string, args ...string) *pty.Cmd {
f.commands = append(f.commands, append([]string{cmd}, args...))
return &pty.Cmd{
Context: ctx,
Path: cmd,
Args: append([]string{cmd}, args...),
Env: []string{},
Dir: "",
}
}

func (f *fakeExecer) getLastCommand() *exec.Cmd {
if len(f.createdCommands) == 0 {
return nil
}
return f.createdCommands[len(f.createdCommands)-1]
}

func TestAPI(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -1970,6 +2005,57 @@ func TestAPI(t *testing.T) {
// Then: We expected it to succeed
require.Len(t, fSAC.created, 1)
})

t.Run("CommandEnv", func(t *testing.T) {
t.Parallel()

ctx := testutil.Context(t, testutil.WaitShort)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)

// Create fake execer to track execution details.
fakeExec := &fakeExecer{}

// Custom CommandEnv that returns specific values.
testShell := "/bin/custom-shell"
testDir := t.TempDir()
testEnv := []string{"CUSTOM_VAR=test_value", "PATH=/custom/path"}

commandEnv := func(ei usershell.EnvInfoer, addEnv []string) (shell, dir string, env []string, err error) {
return testShell, testDir, testEnv, nil
}

mClock := quartz.NewMock(t) // Stop time.

// Create API with CommandEnv.
api := agentcontainers.NewAPI(logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithExecer(fakeExec),
agentcontainers.WithCommandEnv(commandEnv),
)
defer api.Close()

// Call RefreshContainers directly to trigger CommandEnv usage.
_ = api.RefreshContainers(ctx) // Ignore error since docker commands will fail.

// Verify commands were executed through the custom shell and environment.
require.NotEmpty(t, fakeExec.commands, "commands should be executed")

// Want: /bin/custom-shell -c "docker ps --all --quiet --no-trunc"
require.Equal(t, testShell, fakeExec.commands[0][0], "custom shell should be used")
if runtime.GOOS == "windows" {
require.Equal(t, "/c", fakeExec.commands[0][1], "shell should be called with /c on Windows")
} else {
require.Equal(t, "-c", fakeExec.commands[0][1], "shell should be called with -c")
}
require.Len(t, fakeExec.commands[0], 3, "command should have 3 arguments")
require.GreaterOrEqual(t, strings.Count(fakeExec.commands[0][2], " "), 2, "command/script should have multiple arguments")

// Verify the environment was set on the command.
lastCmd := fakeExec.getLastCommand()
require.NotNil(t, lastCmd, "command should be created")
require.Equal(t, testDir, lastCmd.Dir, "custom directory should be used")
require.Equal(t, testEnv, lastCmd.Env, "custom environment should be used")
})
}

// mustFindDevcontainerByPath returns the devcontainer with the given workspace
Expand Down
10 changes: 10 additions & 0 deletions agent/agentcontainers/containers_dockercli.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,10 @@ func (dcli *dockerCLI) List(ctx context.Context) (codersdk.WorkspaceAgentListCon
// container IDs and returns the parsed output.
// The stderr output is also returned for logging purposes.
func runDockerInspect(ctx context.Context, execer agentexec.Execer, ids ...string) (stdout, stderr []byte, err error) {
if ctx.Err() != nil {
// If the context is done, we don't want to run the command.
return []byte{}, []byte{}, ctx.Err()
}
var stdoutBuf, stderrBuf bytes.Buffer
cmd := execer.CommandContext(ctx, "docker", append([]string{"inspect"}, ids...)...)
cmd.Stdout = &stdoutBuf
Expand All @@ -319,6 +323,12 @@ func runDockerInspect(ctx context.Context, execer agentexec.Execer, ids ...strin
stdout = bytes.TrimSpace(stdoutBuf.Bytes())
stderr = bytes.TrimSpace(stderrBuf.Bytes())
if err != nil {
if ctx.Err() != nil {
// If the context was canceled while running the command,
// return the context error instead of the command error,
// which is likely to be "signal: killed".
return stdout, stderr, ctx.Err()
}
if bytes.Contains(stderr, []byte("No such object:")) {
// This can happen if a container is deleted between the time we check for its existence and the time we inspect it.
return stdout, stderr, nil
Expand Down
2 changes: 0 additions & 2 deletions agent/agentcontainers/devcontainercli.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"encoding/json"
"errors"
"io"
"os"

"golang.org/x/xerrors"

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

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

var stdoutBuf bytes.Buffer
Expand Down
77 changes: 77 additions & 0 deletions agent/agentcontainers/execer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package agentcontainers

import (
"context"
"os/exec"
"runtime"

"github.com/kballard/go-shellquote"

"cdr.dev/slog"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/agent/usershell"
"github.com/coder/coder/v2/pty"
)

// CommandEnv is a function that returns the shell, working directory,
// and environment variables to use when executing a command. It takes
// an EnvInfoer and a pre-existing environment slice as arguments.
// This signature matches agentssh.Server.CommandEnv.
type CommandEnv func(ei usershell.EnvInfoer, addEnv []string) (shell, dir string, env []string, err error)

// commandEnvExecer is an agentexec.Execer that uses a CommandEnv to
// determine the shell, working directory, and environment variables
// for commands. It wraps another agentexec.Execer to provide the
// necessary context.
type commandEnvExecer struct {
logger slog.Logger
commandEnv CommandEnv
execer agentexec.Execer
}

func newCommandEnvExecer(
logger slog.Logger,
commandEnv CommandEnv,
execer agentexec.Execer,
) *commandEnvExecer {
return &commandEnvExecer{
logger: logger,
commandEnv: commandEnv,
execer: execer,
}
}

// Ensure commandEnvExecer implements agentexec.Execer.
var _ agentexec.Execer = (*commandEnvExecer)(nil)

func (e *commandEnvExecer) prepare(ctx context.Context, inName string, inArgs ...string) (name string, args []string, dir string, env []string) {
shell, dir, env, err := e.commandEnv(nil, nil)
if err != nil {
e.logger.Error(ctx, "get command environment failed", slog.Error(err))
return inName, inArgs, "", nil
}

caller := "-c"
if runtime.GOOS == "windows" {
caller = "/c"
}
name = shell
args = []string{caller, shellquote.Join(append([]string{inName}, inArgs...)...)}
return name, args, dir, env
}

func (e *commandEnvExecer) CommandContext(ctx context.Context, cmd string, args ...string) *exec.Cmd {
name, args, dir, env := e.prepare(ctx, cmd, args...)
c := e.execer.CommandContext(ctx, name, args...)
c.Dir = dir
c.Env = env
return c
}

func (e *commandEnvExecer) PTYCommandContext(ctx context.Context, cmd string, args ...string) *pty.Cmd {
name, args, dir, env := e.prepare(ctx, cmd, args...)
c := e.execer.PTYCommandContext(ctx, name, args...)
c.Dir = dir
c.Env = env
return c
}
Loading
Loading