From 3b44a89c2a39e7a96312a4a21894b17e3201790c Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 12 Jun 2025 09:43:14 +0000 Subject: [PATCH 1/4] feat(agent/agentcontainers): support displayApps from devcontainer config --- agent/agentcontainers/acmock/acmock.go | 20 +++ agent/agentcontainers/api.go | 12 ++ agent/agentcontainers/api_test.go | 153 +++++++++++++++++- agent/agentcontainers/devcontainercli.go | 114 +++++++++++-- agent/agentcontainers/devcontainercli_test.go | 119 ++++++++++++++ agent/agentcontainers/subagent.go | 25 +++ agent/agentcontainers/subagent_test.go | 106 ++++++++++++ .../read-config-error-not-found.log | 2 + .../read-config-with-coder-customization.log | 8 + ...ead-config-without-coder-customization.log | 8 + agent/agenttest/client.go | 47 ++++-- 11 files changed, 588 insertions(+), 26 deletions(-) create mode 100644 agent/agentcontainers/subagent_test.go create mode 100644 agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-error-not-found.log create mode 100644 agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-with-coder-customization.log create mode 100644 agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-without-coder-customization.log diff --git a/agent/agentcontainers/acmock/acmock.go b/agent/agentcontainers/acmock/acmock.go index f9723e8a15758..990a243a33ddf 100644 --- a/agent/agentcontainers/acmock/acmock.go +++ b/agent/agentcontainers/acmock/acmock.go @@ -149,6 +149,26 @@ func (mr *MockDevcontainerCLIMockRecorder) Exec(ctx, workspaceFolder, configPath return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Exec", reflect.TypeOf((*MockDevcontainerCLI)(nil).Exec), varargs...) } +// ReadConfig mocks base method. +func (m *MockDevcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, configPath string, opts ...agentcontainers.DevcontainerCLIReadConfigOptions) (agentcontainers.DevcontainerConfig, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, workspaceFolder, configPath} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ReadConfig", varargs...) + ret0, _ := ret[0].(agentcontainers.DevcontainerConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReadConfig indicates an expected call of ReadConfig. +func (mr *MockDevcontainerCLIMockRecorder) ReadConfig(ctx, workspaceFolder, configPath any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, workspaceFolder, configPath}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadConfig", reflect.TypeOf((*MockDevcontainerCLI)(nil).ReadConfig), varargs...) +} + // Up mocks base method. func (m *MockDevcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath string, opts ...agentcontainers.DevcontainerCLIUpOptions) (string, error) { m.ctrl.T.Helper() diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 56c5df6710297..6e893f2a53441 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -1099,6 +1099,17 @@ func (api *API) injectSubAgentIntoContainerLocked(ctx context.Context, dc coders directory = DevcontainerDefaultContainerWorkspaceFolder } + var displayApps []codersdk.DisplayApp + + if config, err := api.dccli.ReadConfig(ctx, dc.WorkspaceFolder, dc.ConfigPath); err != nil { + api.logger.Error(ctx, "unable to read devcontainer config", slog.Error(err)) + } else { + coderCustomization := config.Configuration.Customizations.Coder + if coderCustomization != nil { + displayApps = coderCustomization.DisplayApps + } + } + // The preparation of the subagent is done, now we can create the // subagent record in the database to receive the auth token. createdAgent, err := api.subAgentClient.Create(ctx, SubAgent{ @@ -1106,6 +1117,7 @@ func (api *API) injectSubAgentIntoContainerLocked(ctx context.Context, dc coders Directory: directory, OperatingSystem: "linux", // Assuming Linux for dev containers. Architecture: arch, + DisplayApps: displayApps, }) if err != nil { return xerrors.Errorf("create agent: %w", err) diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index 91cebcf2e5d25..154a916e92845 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -60,11 +60,14 @@ func (f *fakeContainerCLI) ExecAs(ctx context.Context, name, user string, args . // fakeDevcontainerCLI implements the agentcontainers.DevcontainerCLI // interface for testing. type fakeDevcontainerCLI struct { - upID string - upErr error - upErrC chan error // If set, send to return err, close to return upErr. - execErr error - execErrC chan func(cmd string, args ...string) error // If set, send fn to return err, nil or close to return execErr. + upID string + upErr error + upErrC chan error // If set, send to return err, close to return upErr. + execErr error + execErrC chan func(cmd string, args ...string) error // If set, send fn to return err, nil or close to return execErr. + readConfig agentcontainers.DevcontainerConfig + readConfigErr error + readConfigErrC chan error } func (f *fakeDevcontainerCLI) Up(ctx context.Context, _, _ string, _ ...agentcontainers.DevcontainerCLIUpOptions) (string, error) { @@ -95,6 +98,20 @@ func (f *fakeDevcontainerCLI) Exec(ctx context.Context, _, _ string, cmd string, return f.execErr } +func (f *fakeDevcontainerCLI) ReadConfig(ctx context.Context, _, _ string, _ ...agentcontainers.DevcontainerCLIReadConfigOptions) (agentcontainers.DevcontainerConfig, error) { + if f.readConfigErrC != nil { + select { + case <-ctx.Done(): + return agentcontainers.DevcontainerConfig{}, ctx.Err() + case err, ok := <-f.readConfigErrC: + if ok { + return f.readConfig, err + } + } + } + return f.readConfig, f.readConfigErr +} + // fakeWatcher implements the watcher.Watcher interface for testing. // It allows controlling what events are sent and when. type fakeWatcher struct { @@ -1132,10 +1149,12 @@ func TestAPI(t *testing.T) { Containers: []codersdk.WorkspaceAgentContainer{container}, }, } + fDCCLI := &fakeDevcontainerCLI{} logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) api := agentcontainers.NewAPI( logger, + agentcontainers.WithDevcontainerCLI(fDCCLI), agentcontainers.WithContainerCLI(fLister), agentcontainers.WithWatcher(fWatcher), agentcontainers.WithClock(mClock), @@ -1421,6 +1440,130 @@ func TestAPI(t *testing.T) { assert.Contains(t, fakeSAC.deleted, existingAgentID) assert.Empty(t, fakeSAC.agents) }) + + t.Run("Create", func(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("Dev Container tests are not supported on Windows (this test uses mocks but fails due to Windows paths)") + } + + tests := []struct { + name string + customization *agentcontainers.CoderCustomization + afterCreate func(t *testing.T, subAgent agentcontainers.SubAgent) + }{ + { + name: "WithoutCustomization", + customization: nil, + }, + { + name: "WithDisplayApps", + customization: &agentcontainers.CoderCustomization{ + DisplayApps: []codersdk.DisplayApp{ + codersdk.DisplayAppSSH, + codersdk.DisplayAppWebTerminal, + codersdk.DisplayAppVSCodeInsiders, + }, + }, + afterCreate: func(t *testing.T, subAgent agentcontainers.SubAgent) { + require.Len(t, subAgent.DisplayApps, 3) + assert.Equal(t, codersdk.DisplayAppSSH, subAgent.DisplayApps[0]) + assert.Equal(t, codersdk.DisplayAppWebTerminal, subAgent.DisplayApps[1]) + assert.Equal(t, codersdk.DisplayAppVSCodeInsiders, subAgent.DisplayApps[2]) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var ( + ctx = testutil.Context(t, testutil.WaitMedium) + logger = testutil.Logger(t) + mClock = quartz.NewMock(t) + mCCLI = acmock.NewMockContainerCLI(gomock.NewController(t)) + fSAC = &fakeSubAgentClient{createErrC: make(chan error, 1)} + fDCCLI = &fakeDevcontainerCLI{ + readConfig: agentcontainers.DevcontainerConfig{ + Configuration: agentcontainers.DevcontainerConfiguration{ + Customizations: agentcontainers.DevcontainerCustomizations{ + Coder: tt.customization, + }, + }, + }, + execErrC: make(chan func(cmd string, args ...string) error, 1), + } + + testContainer = codersdk.WorkspaceAgentContainer{ + ID: "test-container-id", + FriendlyName: "test-container", + Image: "test-image", + Running: true, + CreatedAt: time.Now(), + Labels: map[string]string{ + agentcontainers.DevcontainerLocalFolderLabel: "/workspaces", + agentcontainers.DevcontainerConfigFileLabel: "/workspace/.devcontainer/devcontainer.json", + }, + } + ) + + coderBin, err := os.Executable() + require.NoError(t, err) + + // Mock the `List` function to always return out test container. + mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{testContainer}, + }, nil).AnyTimes() + + // Mock the steps used for injecting the coder agent. + gomock.InOrder( + mCCLI.EXPECT().DetectArchitecture(gomock.Any(), testContainer.ID).Return(runtime.GOARCH, nil), + mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "mkdir", "-p", "/.coder-agent").Return(nil, nil), + mCCLI.EXPECT().Copy(gomock.Any(), testContainer.ID, coderBin, "/.coder-agent/coder").Return(nil), + mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "chmod", "0755", "/.coder-agent", "/.coder-agent/coder").Return(nil, nil), + ) + + mClock.Set(time.Now()).MustWait(ctx) + tickerTrap := mClock.Trap().TickerFunc("updaterLoop") + + api := agentcontainers.NewAPI(logger, + agentcontainers.WithClock(mClock), + agentcontainers.WithContainerCLI(mCCLI), + agentcontainers.WithDevcontainerCLI(fDCCLI), + agentcontainers.WithSubAgentClient(fSAC), + agentcontainers.WithSubAgentURL("test-subagent-url"), + agentcontainers.WithWatcher(watcher.NewNoop()), + ) + defer api.Close() + + // Close before api.Close() defer to avoid deadlock after test. + defer close(fSAC.createErrC) + defer close(fDCCLI.execErrC) + + // Given: We allow agent creation and injection to succeed. + testutil.RequireSend(ctx, t, fSAC.createErrC, nil) + testutil.RequireSend(ctx, t, fDCCLI.execErrC, func(cmd string, args ...string) error { + assert.Equal(t, "pwd", cmd) + assert.Empty(t, args) + return nil + }) + + // Wait until the ticker has been registered. + tickerTrap.MustWait(ctx).MustRelease(ctx) + tickerTrap.Close() + + // Then: We expected it to succeed + require.Len(t, fSAC.created, 1) + assert.Equal(t, testContainer.FriendlyName, fSAC.created[0].Name) + + if tt.afterCreate != nil { + tt.afterCreate(t, fSAC.created[0]) + } + }) + } + }) } // mustFindDevcontainerByPath returns the devcontainer with the given workspace diff --git a/agent/agentcontainers/devcontainercli.go b/agent/agentcontainers/devcontainercli.go index 4e1ad93a715dc..7460f9b8baa36 100644 --- a/agent/agentcontainers/devcontainercli.go +++ b/agent/agentcontainers/devcontainercli.go @@ -12,12 +12,33 @@ import ( "cdr.dev/slog" "github.com/coder/coder/v2/agent/agentexec" + "github.com/coder/coder/v2/codersdk" ) +// DevcontainerConfig is a wrapper around the output from `read-configuration`. +// Unfortunately we cannot make use of `dcspec` as the output doesn't appear to +// match. +type DevcontainerConfig struct { + Configuration DevcontainerConfiguration `json:"configuration"` +} + +type DevcontainerConfiguration struct { + Customizations DevcontainerCustomizations `json:"customizations,omitempty"` +} + +type DevcontainerCustomizations struct { + Coder *CoderCustomization `json:"com.coder,omitempty"` +} + +type CoderCustomization struct { + DisplayApps []codersdk.DisplayApp `json:"displayApps,omitempty"` +} + // DevcontainerCLI is an interface for the devcontainer CLI. type DevcontainerCLI interface { Up(ctx context.Context, workspaceFolder, configPath string, opts ...DevcontainerCLIUpOptions) (id string, err error) Exec(ctx context.Context, workspaceFolder, configPath string, cmd string, cmdArgs []string, opts ...DevcontainerCLIExecOptions) error + ReadConfig(ctx context.Context, workspaceFolder, configPath string, opts ...DevcontainerCLIReadConfigOptions) (DevcontainerConfig, error) } // DevcontainerCLIUpOptions are options for the devcontainer CLI Up @@ -83,6 +104,24 @@ func WithRemoteEnv(env ...string) DevcontainerCLIExecOptions { } } +// DevcontainerCLIExecOptions are options for the devcontainer CLI ReadConfig +// command. +type DevcontainerCLIReadConfigOptions func(*devcontainerCLIReadConfigConfig) + +type devcontainerCLIReadConfigConfig struct { + stdout io.Writer + stderr io.Writer +} + +// WithExecOutput sets additional stdout and stderr writers for logs +// during Exec operations. +func WithReadConfigOutput(stdout, stderr io.Writer) DevcontainerCLIReadConfigOptions { + return func(o *devcontainerCLIReadConfigConfig) { + o.stdout = stdout + o.stderr = stderr + } +} + func applyDevcontainerCLIUpOptions(opts []DevcontainerCLIUpOptions) devcontainerCLIUpConfig { conf := devcontainerCLIUpConfig{} for _, opt := range opts { @@ -103,6 +142,16 @@ func applyDevcontainerCLIExecOptions(opts []DevcontainerCLIExecOptions) devconta return conf } +func applyDevcontainerCLIReadConfigOptions(opts []DevcontainerCLIReadConfigOptions) devcontainerCLIReadConfigConfig { + conf := devcontainerCLIReadConfigConfig{} + for _, opt := range opts { + if opt != nil { + opt(&conf) + } + } + return conf +} + type devcontainerCLI struct { logger slog.Logger execer agentexec.Execer @@ -147,14 +196,21 @@ func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath st cmd.Stderr = io.MultiWriter(stderrWriters...) if err := cmd.Run(); err != nil { - if _, err2 := parseDevcontainerCLILastLine(ctx, logger, stdoutBuf.Bytes()); err2 != nil { + var result devcontainerCLIResult + if err2 := parseDevcontainerCLILastLine(ctx, logger, stdoutBuf.Bytes(), &result); err2 != nil { + err = errors.Join(err, err2) + } + if err2 := result.Err(); err2 != nil { err = errors.Join(err, err2) } return "", err } - result, err := parseDevcontainerCLILastLine(ctx, logger, stdoutBuf.Bytes()) - if err != nil { + var result devcontainerCLIResult + if err := parseDevcontainerCLILastLine(ctx, logger, stdoutBuf.Bytes(), &result); err != nil { + return "", err + } + if err := result.Err(); err != nil { return "", err } @@ -200,9 +256,47 @@ func (d *devcontainerCLI) Exec(ctx context.Context, workspaceFolder, configPath return nil } +func (d *devcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, configPath string, opts ...DevcontainerCLIReadConfigOptions) (DevcontainerConfig, error) { + conf := applyDevcontainerCLIReadConfigOptions(opts) + logger := d.logger.With(slog.F("workspace_folder", workspaceFolder), slog.F("config_path", configPath)) + + args := []string{"read-configuration"} + if workspaceFolder != "" { + args = append(args, "--workspace-folder", workspaceFolder) + } + if configPath != "" { + args = append(args, "--config", configPath) + } + + c := d.execer.CommandContext(ctx, "devcontainer", args...) + + var stdoutBuf bytes.Buffer + stdoutWriters := []io.Writer{&stdoutBuf, &devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stdout", true))}} + if conf.stdout != nil { + stdoutWriters = append(stdoutWriters, conf.stdout) + } + c.Stdout = io.MultiWriter(stdoutWriters...) + stderrWriters := []io.Writer{&devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stderr", true))}} + if conf.stderr != nil { + stderrWriters = append(stderrWriters, conf.stderr) + } + c.Stderr = io.MultiWriter(stderrWriters...) + + if err := c.Run(); err != nil { + return DevcontainerConfig{}, xerrors.Errorf("devcontainer read-configuration failed: %w", err) + } + + var config DevcontainerConfig + if err := parseDevcontainerCLILastLine(ctx, logger, stdoutBuf.Bytes(), &config); err != nil { + return DevcontainerConfig{}, err + } + + return config, nil +} + // parseDevcontainerCLILastLine parses the last line of the devcontainer CLI output // which is a JSON object. -func parseDevcontainerCLILastLine(ctx context.Context, logger slog.Logger, p []byte) (result devcontainerCLIResult, err error) { +func parseDevcontainerCLILastLine[T any](ctx context.Context, logger slog.Logger, p []byte, result T) error { s := bufio.NewScanner(bytes.NewReader(p)) var lastLine []byte for s.Scan() { @@ -212,19 +306,19 @@ func parseDevcontainerCLILastLine(ctx context.Context, logger slog.Logger, p []b } lastLine = b } - if err = s.Err(); err != nil { - return result, err + if err := s.Err(); err != nil { + return err } if len(lastLine) == 0 || lastLine[0] != '{' { logger.Error(ctx, "devcontainer result is not json", slog.F("result", string(lastLine))) - return result, xerrors.Errorf("devcontainer result is not json: %q", string(lastLine)) + return xerrors.Errorf("devcontainer result is not json: %q", string(lastLine)) } - if err = json.Unmarshal(lastLine, &result); err != nil { + if err := json.Unmarshal(lastLine, &result); err != nil { logger.Error(ctx, "parse devcontainer result failed", slog.Error(err), slog.F("result", string(lastLine))) - return result, err + return err } - return result, result.Err() + return nil } // devcontainerCLIResult is the result of the devcontainer CLI command. diff --git a/agent/agentcontainers/devcontainercli_test.go b/agent/agentcontainers/devcontainercli_test.go index b8b4120d2e8ab..9744f736d302f 100644 --- a/agent/agentcontainers/devcontainercli_test.go +++ b/agent/agentcontainers/devcontainercli_test.go @@ -22,6 +22,7 @@ import ( "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/agent/agentcontainers" "github.com/coder/coder/v2/agent/agentexec" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/pty" "github.com/coder/coder/v2/testutil" ) @@ -233,6 +234,91 @@ func TestDevcontainerCLI_ArgsAndParsing(t *testing.T) { }) } }) + + t.Run("ReadConfig", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + logFile string + workspaceFolder string + configPath string + opts []agentcontainers.DevcontainerCLIReadConfigOptions + wantArgs string + wantError bool + wantConfig agentcontainers.DevcontainerConfig + }{ + { + name: "WithCoderCustomization", + logFile: "read-config-with-coder-customization.log", + workspaceFolder: "/test/workspace", + configPath: "", + wantArgs: "read-configuration --workspace-folder /test/workspace", + wantError: false, + wantConfig: agentcontainers.DevcontainerConfig{ + Configuration: agentcontainers.DevcontainerConfiguration{ + Customizations: agentcontainers.DevcontainerCustomizations{ + Coder: &agentcontainers.CoderCustomization{ + DisplayApps: []codersdk.DisplayApp{ + codersdk.DisplayAppVSCodeDesktop, + codersdk.DisplayAppWebTerminal, + }, + }, + }, + }, + }, + }, + { + name: "WithoutCoderCustomization", + logFile: "read-config-without-coder-customization.log", + workspaceFolder: "/test/workspace", + configPath: "/test/config.json", + wantArgs: "read-configuration --workspace-folder /test/workspace --config /test/config.json", + wantError: false, + wantConfig: agentcontainers.DevcontainerConfig{ + Configuration: agentcontainers.DevcontainerConfiguration{ + Customizations: agentcontainers.DevcontainerCustomizations{ + Coder: nil, + }, + }, + }, + }, + { + name: "FileNotFound", + logFile: "read-config-error-not-found.log", + workspaceFolder: "/nonexistent/workspace", + configPath: "", + wantArgs: "read-configuration --workspace-folder /nonexistent/workspace", + wantError: true, + wantConfig: agentcontainers.DevcontainerConfig{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitMedium) + + testExecer := &testDevcontainerExecer{ + testExePath: testExePath, + wantArgs: tt.wantArgs, + wantError: tt.wantError, + logFile: filepath.Join("testdata", "devcontainercli", "readconfig", tt.logFile), + } + + dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer) + config, err := dccli.ReadConfig(ctx, tt.workspaceFolder, tt.configPath, tt.opts...) + if tt.wantError { + assert.Error(t, err, "want error") + assert.Equal(t, agentcontainers.DevcontainerConfig{}, config, "expected empty config on error") + } else { + assert.NoError(t, err, "want no error") + assert.Equal(t, tt.wantConfig, config, "expected config to match") + } + }) + } + }) } // TestDevcontainerCLI_WithOutput tests that WithUpOutput and WithExecOutput capture CLI @@ -314,6 +400,39 @@ func TestDevcontainerCLI_WithOutput(t *testing.T) { assert.NotEmpty(t, outBuf.String(), "stdout buffer should not be empty for exec with log file") assert.Empty(t, errBuf.String(), "stderr buffer should be empty") }) + + t.Run("ReadConfig", func(t *testing.T) { + t.Parallel() + + // Buffers to capture stdout and stderr. + outBuf := &bytes.Buffer{} + errBuf := &bytes.Buffer{} + + // Simulate CLI execution with a read-config-success.log file. + wantArgs := "read-configuration --workspace-folder /test/workspace --config /test/config.json" + testExecer := &testDevcontainerExecer{ + testExePath: testExePath, + wantArgs: wantArgs, + wantError: false, + logFile: filepath.Join("testdata", "devcontainercli", "readconfig", "read-config-success.log"), + } + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer) + + // Call ReadConfig with WithReadConfigOutput to capture CLI logs. + ctx := testutil.Context(t, testutil.WaitMedium) + config, err := dccli.ReadConfig(ctx, "/test/workspace", "/test/config.json", agentcontainers.WithReadConfigOutput(outBuf, errBuf)) + require.NoError(t, err, "ReadConfig should succeed") + require.NotEmpty(t, config.Configuration.Customizations, "expected non-empty customizations") + + // Read expected log content. + expLog, err := os.ReadFile(filepath.Join("testdata", "devcontainercli", "readconfig", "read-config-success.log")) + require.NoError(t, err, "reading expected log file") + + // Verify stdout buffer contains the CLI logs and stderr is empty. + assert.Equal(t, string(expLog), outBuf.String(), "stdout buffer should match CLI logs") + assert.Empty(t, errBuf.String(), "stderr buffer should be empty on success") + }) } // testDevcontainerExecer implements the agentexec.Execer interface for testing. diff --git a/agent/agentcontainers/subagent.go b/agent/agentcontainers/subagent.go index 70899fb96f70d..5848e5747e099 100644 --- a/agent/agentcontainers/subagent.go +++ b/agent/agentcontainers/subagent.go @@ -9,6 +9,7 @@ import ( "cdr.dev/slog" agentproto "github.com/coder/coder/v2/agent/proto" + "github.com/coder/coder/v2/codersdk" ) // SubAgent represents an agent running in a dev container. @@ -19,6 +20,7 @@ type SubAgent struct { Directory string Architecture string OperatingSystem string + DisplayApps []codersdk.DisplayApp } // SubAgentClient is an interface for managing sub agents and allows @@ -80,11 +82,34 @@ func (a *subAgentAPIClient) List(ctx context.Context) ([]SubAgent, error) { func (a *subAgentAPIClient) Create(ctx context.Context, agent SubAgent) (SubAgent, error) { a.logger.Debug(ctx, "creating sub agent", slog.F("name", agent.Name), slog.F("directory", agent.Directory)) + + displayApps := make([]agentproto.CreateSubAgentRequest_DisplayApp, 0, len(agent.DisplayApps)) + for _, displayApp := range agent.DisplayApps { + var app agentproto.CreateSubAgentRequest_DisplayApp + switch displayApp { + case codersdk.DisplayAppPortForward: + app = agentproto.CreateSubAgentRequest_PORT_FORWARDING_HELPER + case codersdk.DisplayAppSSH: + app = agentproto.CreateSubAgentRequest_SSH_HELPER + case codersdk.DisplayAppVSCodeDesktop: + app = agentproto.CreateSubAgentRequest_VSCODE + case codersdk.DisplayAppVSCodeInsiders: + app = agentproto.CreateSubAgentRequest_VSCODE_INSIDERS + case codersdk.DisplayAppWebTerminal: + app = agentproto.CreateSubAgentRequest_WEB_TERMINAL + default: + return SubAgent{}, xerrors.Errorf("unexpected codersdk.DisplayApp: %#v", displayApp) + } + + displayApps = append(displayApps, app) + } + resp, err := a.api.CreateSubAgent(ctx, &agentproto.CreateSubAgentRequest{ Name: agent.Name, Directory: agent.Directory, Architecture: agent.Architecture, OperatingSystem: agent.OperatingSystem, + DisplayApps: displayApps, }) if err != nil { return SubAgent{}, err diff --git a/agent/agentcontainers/subagent_test.go b/agent/agentcontainers/subagent_test.go new file mode 100644 index 0000000000000..30e90fdee76af --- /dev/null +++ b/agent/agentcontainers/subagent_test.go @@ -0,0 +1,106 @@ +package agentcontainers_test + +import ( + "testing" + + "github.com/coder/coder/v2/agent/agentcontainers" + "github.com/coder/coder/v2/agent/agenttest" + "github.com/coder/coder/v2/agent/proto" + agentproto "github.com/coder/coder/v2/agent/proto" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/tailnet" + "github.com/coder/coder/v2/testutil" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestSubAgentClient_CreateWithDisplayApps(t *testing.T) { + t.Parallel() + + t.Run("CreateWithDisplayApps", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + displayApps []codersdk.DisplayApp + expectedApps []agentproto.CreateSubAgentRequest_DisplayApp + }{ + { + name: "single display app", + displayApps: []codersdk.DisplayApp{codersdk.DisplayAppVSCodeDesktop}, + expectedApps: []agentproto.CreateSubAgentRequest_DisplayApp{ + agentproto.CreateSubAgentRequest_VSCODE, + }, + }, + { + name: "multiple display apps", + displayApps: []codersdk.DisplayApp{ + codersdk.DisplayAppVSCodeDesktop, + codersdk.DisplayAppSSH, + codersdk.DisplayAppPortForward, + }, + expectedApps: []agentproto.CreateSubAgentRequest_DisplayApp{ + agentproto.CreateSubAgentRequest_VSCODE, + agentproto.CreateSubAgentRequest_SSH_HELPER, + agentproto.CreateSubAgentRequest_PORT_FORWARDING_HELPER, + }, + }, + { + name: "all display apps", + displayApps: []codersdk.DisplayApp{ + codersdk.DisplayAppPortForward, + codersdk.DisplayAppSSH, + codersdk.DisplayAppVSCodeDesktop, + codersdk.DisplayAppVSCodeInsiders, + codersdk.DisplayAppWebTerminal, + }, + expectedApps: []agentproto.CreateSubAgentRequest_DisplayApp{ + agentproto.CreateSubAgentRequest_PORT_FORWARDING_HELPER, + agentproto.CreateSubAgentRequest_SSH_HELPER, + agentproto.CreateSubAgentRequest_VSCODE, + agentproto.CreateSubAgentRequest_VSCODE_INSIDERS, + agentproto.CreateSubAgentRequest_WEB_TERMINAL, + }, + }, + { + name: "no display apps", + displayApps: []codersdk.DisplayApp{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + logger := testutil.Logger(t) + statsCh := make(chan *proto.Stats) + + agentAPI := agenttest.NewClient(t, logger, uuid.New(), agentsdk.Manifest{}, statsCh, tailnet.NewCoordinator(logger)) + + agentClient, _, err := agentAPI.ConnectRPC26(ctx) + require.NoError(t, err) + + subAgentClient := agentcontainers.NewSubAgentClientFromAPI(logger, agentClient) + + // When: We create a sub agent with display apps. + subAgent, err := subAgentClient.Create(ctx, agentcontainers.SubAgent{ + Name: "sub-agent-" + tt.name, + Directory: "/workspaces/coder", + Architecture: "amd64", + OperatingSystem: "linux", + DisplayApps: tt.displayApps, + }) + require.NoError(t, err) + + displayApps, err := agentAPI.GetSubAgentDisplayApps(subAgent.ID) + require.NoError(t, err) + + // Then: We expect the apps to be created. + require.Equal(t, tt.expectedApps, displayApps) + }) + } + }) + +} diff --git a/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-error-not-found.log b/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-error-not-found.log new file mode 100644 index 0000000000000..45d66957a3ba1 --- /dev/null +++ b/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-error-not-found.log @@ -0,0 +1,2 @@ +{"type":"text","level":3,"timestamp":1749557935646,"text":"@devcontainers/cli 0.75.0. Node.js v20.16.0. linux 6.8.0-60-generic x64."} +{"type":"text","level":2,"timestamp":1749557935646,"text":"Error: Dev container config (/home/coder/.devcontainer/devcontainer.json) not found.\n at v7 (/usr/local/nvm/versions/node/v20.16.0/lib/node_modules/@devcontainers/cli/dist/spec-node/devContainersSpecCLI.js:668:6918)\n at async /usr/local/nvm/versions/node/v20.16.0/lib/node_modules/@devcontainers/cli/dist/spec-node/devContainersSpecCLI.js:484:1188"} diff --git a/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-with-coder-customization.log b/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-with-coder-customization.log new file mode 100644 index 0000000000000..4543fa3727542 --- /dev/null +++ b/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-with-coder-customization.log @@ -0,0 +1,8 @@ +{"type":"text","level":3,"timestamp":1749557820014,"text":"@devcontainers/cli 0.75.0. Node.js v20.16.0. linux 6.8.0-60-generic x64."} +{"type":"start","level":2,"timestamp":1749557820014,"text":"Run: git rev-parse --show-cdup"} +{"type":"stop","level":2,"timestamp":1749557820023,"text":"Run: git rev-parse --show-cdup","startTimestamp":1749557820014} +{"type":"start","level":2,"timestamp":1749557820023,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder --filter label=devcontainer.config_file=/home/coder/coder/.devcontainer/devcontainer.json"} +{"type":"stop","level":2,"timestamp":1749557820039,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder --filter label=devcontainer.config_file=/home/coder/coder/.devcontainer/devcontainer.json","startTimestamp":1749557820023} +{"type":"start","level":2,"timestamp":1749557820039,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder"} +{"type":"stop","level":2,"timestamp":1749557820054,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder","startTimestamp":1749557820039} +{"configuration":{"customizations":{"com.coder":{"displayApps":["vscode", "web_terminal"]}}}} diff --git a/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-without-coder-customization.log b/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-without-coder-customization.log new file mode 100644 index 0000000000000..99d682e541ea3 --- /dev/null +++ b/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-without-coder-customization.log @@ -0,0 +1,8 @@ +{"type":"text","level":3,"timestamp":1749557820014,"text":"@devcontainers/cli 0.75.0. Node.js v20.16.0. linux 6.8.0-60-generic x64."} +{"type":"start","level":2,"timestamp":1749557820014,"text":"Run: git rev-parse --show-cdup"} +{"type":"stop","level":2,"timestamp":1749557820023,"text":"Run: git rev-parse --show-cdup","startTimestamp":1749557820014} +{"type":"start","level":2,"timestamp":1749557820023,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder --filter label=devcontainer.config_file=/home/coder/coder/.devcontainer/devcontainer.json"} +{"type":"stop","level":2,"timestamp":1749557820039,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder --filter label=devcontainer.config_file=/home/coder/coder/.devcontainer/devcontainer.json","startTimestamp":1749557820023} +{"type":"start","level":2,"timestamp":1749557820039,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder"} +{"type":"stop","level":2,"timestamp":1749557820054,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder","startTimestamp":1749557820039} +{"configuration":{"customizations":{}}} diff --git a/agent/agenttest/client.go b/agent/agenttest/client.go index 0a2df141ff3d4..0fc8a38af80b6 100644 --- a/agent/agenttest/client.go +++ b/agent/agenttest/client.go @@ -171,22 +171,27 @@ func (c *Client) GetSubAgentDirectory(id uuid.UUID) (string, error) { return c.fakeAgentAPI.GetSubAgentDirectory(id) } +func (c *Client) GetSubAgentDisplayApps(id uuid.UUID) ([]agentproto.CreateSubAgentRequest_DisplayApp, error) { + return c.fakeAgentAPI.GetSubAgentDisplayApps(id) +} + type FakeAgentAPI struct { sync.Mutex t testing.TB logger slog.Logger - manifest *agentproto.Manifest - startupCh chan *agentproto.Startup - statsCh chan *agentproto.Stats - appHealthCh chan *agentproto.BatchUpdateAppHealthRequest - logsCh chan<- *agentproto.BatchCreateLogsRequest - lifecycleStates []codersdk.WorkspaceAgentLifecycle - metadata map[string]agentsdk.Metadata - timings []*agentproto.Timing - connectionReports []*agentproto.ReportConnectionRequest - subAgents map[uuid.UUID]*agentproto.SubAgent - subAgentDirs map[uuid.UUID]string + manifest *agentproto.Manifest + startupCh chan *agentproto.Startup + statsCh chan *agentproto.Stats + appHealthCh chan *agentproto.BatchUpdateAppHealthRequest + logsCh chan<- *agentproto.BatchCreateLogsRequest + lifecycleStates []codersdk.WorkspaceAgentLifecycle + metadata map[string]agentsdk.Metadata + timings []*agentproto.Timing + connectionReports []*agentproto.ReportConnectionRequest + subAgents map[uuid.UUID]*agentproto.SubAgent + subAgentDirs map[uuid.UUID]string + subAgentDisplayApps map[uuid.UUID][]agentproto.CreateSubAgentRequest_DisplayApp getAnnouncementBannersFunc func() ([]codersdk.BannerConfig, error) getResourcesMonitoringConfigurationFunc func() (*agentproto.GetResourcesMonitoringConfigurationResponse, error) @@ -401,6 +406,10 @@ func (f *FakeAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Creat f.subAgentDirs = make(map[uuid.UUID]string) } f.subAgentDirs[subAgentID] = req.GetDirectory() + if f.subAgentDisplayApps == nil { + f.subAgentDisplayApps = make(map[uuid.UUID][]agentproto.CreateSubAgentRequest_DisplayApp) + } + f.subAgentDisplayApps[subAgentID] = req.GetDisplayApps() // For a fake implementation, we don't create workspace apps. // Real implementations would handle req.Apps here. @@ -477,6 +486,22 @@ func (f *FakeAgentAPI) GetSubAgentDirectory(id uuid.UUID) (string, error) { return dir, nil } +func (f *FakeAgentAPI) GetSubAgentDisplayApps(id uuid.UUID) ([]agentproto.CreateSubAgentRequest_DisplayApp, error) { + f.Lock() + defer f.Unlock() + + if f.subAgentDisplayApps == nil { + return nil, xerrors.New("no sub-agent display apps available") + } + + displayApps, ok := f.subAgentDisplayApps[id] + if !ok { + return nil, xerrors.New("sub-agent display apps not found") + } + + return displayApps, nil +} + func NewFakeAgentAPI(t testing.TB, logger slog.Logger, manifest *agentproto.Manifest, statsCh chan *agentproto.Stats) *FakeAgentAPI { return &FakeAgentAPI{ t: t, From e12ccc73478d4d2c0b8e5ed91beef8a260a9b777 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 12 Jun 2025 10:19:11 +0000 Subject: [PATCH 2/4] chore: appease linter and remove duplicated test --- agent/agentcontainers/devcontainercli_test.go | 33 ------------------- agent/agentcontainers/subagent_test.go | 9 +++-- 2 files changed, 4 insertions(+), 38 deletions(-) diff --git a/agent/agentcontainers/devcontainercli_test.go b/agent/agentcontainers/devcontainercli_test.go index 9744f736d302f..0f4b967db6ce9 100644 --- a/agent/agentcontainers/devcontainercli_test.go +++ b/agent/agentcontainers/devcontainercli_test.go @@ -400,39 +400,6 @@ func TestDevcontainerCLI_WithOutput(t *testing.T) { assert.NotEmpty(t, outBuf.String(), "stdout buffer should not be empty for exec with log file") assert.Empty(t, errBuf.String(), "stderr buffer should be empty") }) - - t.Run("ReadConfig", func(t *testing.T) { - t.Parallel() - - // Buffers to capture stdout and stderr. - outBuf := &bytes.Buffer{} - errBuf := &bytes.Buffer{} - - // Simulate CLI execution with a read-config-success.log file. - wantArgs := "read-configuration --workspace-folder /test/workspace --config /test/config.json" - testExecer := &testDevcontainerExecer{ - testExePath: testExePath, - wantArgs: wantArgs, - wantError: false, - logFile: filepath.Join("testdata", "devcontainercli", "readconfig", "read-config-success.log"), - } - logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) - dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer) - - // Call ReadConfig with WithReadConfigOutput to capture CLI logs. - ctx := testutil.Context(t, testutil.WaitMedium) - config, err := dccli.ReadConfig(ctx, "/test/workspace", "/test/config.json", agentcontainers.WithReadConfigOutput(outBuf, errBuf)) - require.NoError(t, err, "ReadConfig should succeed") - require.NotEmpty(t, config.Configuration.Customizations, "expected non-empty customizations") - - // Read expected log content. - expLog, err := os.ReadFile(filepath.Join("testdata", "devcontainercli", "readconfig", "read-config-success.log")) - require.NoError(t, err, "reading expected log file") - - // Verify stdout buffer contains the CLI logs and stderr is empty. - assert.Equal(t, string(expLog), outBuf.String(), "stdout buffer should match CLI logs") - assert.Empty(t, errBuf.String(), "stderr buffer should be empty on success") - }) } // testDevcontainerExecer implements the agentexec.Execer interface for testing. diff --git a/agent/agentcontainers/subagent_test.go b/agent/agentcontainers/subagent_test.go index 30e90fdee76af..4b805d7549fce 100644 --- a/agent/agentcontainers/subagent_test.go +++ b/agent/agentcontainers/subagent_test.go @@ -3,16 +3,16 @@ package agentcontainers_test import ( "testing" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/agent/agentcontainers" "github.com/coder/coder/v2/agent/agenttest" - "github.com/coder/coder/v2/agent/proto" agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/tailnet" "github.com/coder/coder/v2/testutil" - "github.com/google/uuid" - "github.com/stretchr/testify/require" ) func TestSubAgentClient_CreateWithDisplayApps(t *testing.T) { @@ -75,7 +75,7 @@ func TestSubAgentClient_CreateWithDisplayApps(t *testing.T) { ctx := testutil.Context(t, testutil.WaitShort) logger := testutil.Logger(t) - statsCh := make(chan *proto.Stats) + statsCh := make(chan *agentproto.Stats) agentAPI := agenttest.NewClient(t, logger, uuid.New(), agentsdk.Manifest{}, statsCh, tailnet.NewCoordinator(logger)) @@ -102,5 +102,4 @@ func TestSubAgentClient_CreateWithDisplayApps(t *testing.T) { }) } }) - } From 4b6209e17c368ae9efb018b5f65a88963a465fc6 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 12 Jun 2025 12:02:14 +0000 Subject: [PATCH 3/4] chore: listen to feedback --- agent/agentcontainers/devcontainercli.go | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/agent/agentcontainers/devcontainercli.go b/agent/agentcontainers/devcontainercli.go index 7460f9b8baa36..baa3223949300 100644 --- a/agent/agentcontainers/devcontainercli.go +++ b/agent/agentcontainers/devcontainercli.go @@ -196,8 +196,8 @@ func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath st cmd.Stderr = io.MultiWriter(stderrWriters...) if err := cmd.Run(); err != nil { - var result devcontainerCLIResult - if err2 := parseDevcontainerCLILastLine(ctx, logger, stdoutBuf.Bytes(), &result); err2 != nil { + result, err2 := parseDevcontainerCLILastLine[devcontainerCLIResult](ctx, logger, stdoutBuf.Bytes()) + if err2 != nil { err = errors.Join(err, err2) } if err2 := result.Err(); err2 != nil { @@ -206,8 +206,8 @@ func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath st return "", err } - var result devcontainerCLIResult - if err := parseDevcontainerCLILastLine(ctx, logger, stdoutBuf.Bytes(), &result); err != nil { + result, err := parseDevcontainerCLILastLine[devcontainerCLIResult](ctx, logger, stdoutBuf.Bytes()) + if err != nil { return "", err } if err := result.Err(); err != nil { @@ -286,8 +286,8 @@ func (d *devcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, confi return DevcontainerConfig{}, xerrors.Errorf("devcontainer read-configuration failed: %w", err) } - var config DevcontainerConfig - if err := parseDevcontainerCLILastLine(ctx, logger, stdoutBuf.Bytes(), &config); err != nil { + config, err := parseDevcontainerCLILastLine[DevcontainerConfig](ctx, logger, stdoutBuf.Bytes()) + if err != nil { return DevcontainerConfig{}, err } @@ -296,7 +296,9 @@ func (d *devcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, confi // parseDevcontainerCLILastLine parses the last line of the devcontainer CLI output // which is a JSON object. -func parseDevcontainerCLILastLine[T any](ctx context.Context, logger slog.Logger, p []byte, result T) error { +func parseDevcontainerCLILastLine[T any](ctx context.Context, logger slog.Logger, p []byte) (T, error) { + var result T + s := bufio.NewScanner(bytes.NewReader(p)) var lastLine []byte for s.Scan() { @@ -307,18 +309,18 @@ func parseDevcontainerCLILastLine[T any](ctx context.Context, logger slog.Logger lastLine = b } if err := s.Err(); err != nil { - return err + return result, err } if len(lastLine) == 0 || lastLine[0] != '{' { logger.Error(ctx, "devcontainer result is not json", slog.F("result", string(lastLine))) - return xerrors.Errorf("devcontainer result is not json: %q", string(lastLine)) + return result, xerrors.Errorf("devcontainer result is not json: %q", string(lastLine)) } if err := json.Unmarshal(lastLine, &result); err != nil { logger.Error(ctx, "parse devcontainer result failed", slog.Error(err), slog.F("result", string(lastLine))) - return err + return result, err } - return nil + return result, nil } // devcontainerCLIResult is the result of the devcontainer CLI command. From aa77e9ee82f48b45870138d0278ab2c2a1447e78 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 12 Jun 2025 19:09:50 +0000 Subject: [PATCH 4/4] chore: include merged, com.coder to coder, UnmarshallJSON --- agent/agentcontainers/api.go | 2 +- agent/agentcontainers/api_test.go | 2 +- agent/agentcontainers/devcontainercli.go | 26 ++++++++++++------- agent/agentcontainers/devcontainercli_test.go | 10 +++---- .../read-config-with-coder-customization.log | 2 +- ...ead-config-without-coder-customization.log | 2 +- 6 files changed, 25 insertions(+), 19 deletions(-) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 6e893f2a53441..ce252fe2909ab 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -1104,7 +1104,7 @@ func (api *API) injectSubAgentIntoContainerLocked(ctx context.Context, dc coders if config, err := api.dccli.ReadConfig(ctx, dc.WorkspaceFolder, dc.ConfigPath); err != nil { api.logger.Error(ctx, "unable to read devcontainer config", slog.Error(err)) } else { - coderCustomization := config.Configuration.Customizations.Coder + coderCustomization := config.MergedConfiguration.Customizations.Coder if coderCustomization != nil { displayApps = coderCustomization.DisplayApps } diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index 154a916e92845..d8e696e151db2 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -1487,7 +1487,7 @@ func TestAPI(t *testing.T) { fSAC = &fakeSubAgentClient{createErrC: make(chan error, 1)} fDCCLI = &fakeDevcontainerCLI{ readConfig: agentcontainers.DevcontainerConfig{ - Configuration: agentcontainers.DevcontainerConfiguration{ + MergedConfiguration: agentcontainers.DevcontainerConfiguration{ Customizations: agentcontainers.DevcontainerCustomizations{ Coder: tt.customization, }, diff --git a/agent/agentcontainers/devcontainercli.go b/agent/agentcontainers/devcontainercli.go index baa3223949300..2fad8c6560067 100644 --- a/agent/agentcontainers/devcontainercli.go +++ b/agent/agentcontainers/devcontainercli.go @@ -19,7 +19,7 @@ import ( // Unfortunately we cannot make use of `dcspec` as the output doesn't appear to // match. type DevcontainerConfig struct { - Configuration DevcontainerConfiguration `json:"configuration"` + MergedConfiguration DevcontainerConfiguration `json:"mergedConfiguration"` } type DevcontainerConfiguration struct { @@ -27,7 +27,7 @@ type DevcontainerConfiguration struct { } type DevcontainerCustomizations struct { - Coder *CoderCustomization `json:"com.coder,omitempty"` + Coder *CoderCustomization `json:"coder,omitempty"` } type CoderCustomization struct { @@ -196,13 +196,10 @@ func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath st cmd.Stderr = io.MultiWriter(stderrWriters...) if err := cmd.Run(); err != nil { - result, err2 := parseDevcontainerCLILastLine[devcontainerCLIResult](ctx, logger, stdoutBuf.Bytes()) + _, err2 := parseDevcontainerCLILastLine[devcontainerCLIResult](ctx, logger, stdoutBuf.Bytes()) if err2 != nil { err = errors.Join(err, err2) } - if err2 := result.Err(); err2 != nil { - err = errors.Join(err, err2) - } return "", err } @@ -210,9 +207,6 @@ func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath st if err != nil { return "", err } - if err := result.Err(); err != nil { - return "", err - } return result.ContainerID, nil } @@ -260,7 +254,7 @@ func (d *devcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, confi conf := applyDevcontainerCLIReadConfigOptions(opts) logger := d.logger.With(slog.F("workspace_folder", workspaceFolder), slog.F("config_path", configPath)) - args := []string{"read-configuration"} + args := []string{"read-configuration", "--include-merged-configuration"} if workspaceFolder != "" { args = append(args, "--workspace-folder", workspaceFolder) } @@ -339,6 +333,18 @@ type devcontainerCLIResult struct { Description string `json:"description"` } +func (r *devcontainerCLIResult) UnmarshalJSON(data []byte) error { + type wrapperResult devcontainerCLIResult + + var wrappedResult wrapperResult + if err := json.Unmarshal(data, &wrappedResult); err != nil { + return err + } + + *r = devcontainerCLIResult(wrappedResult) + return r.Err() +} + func (r devcontainerCLIResult) Err() error { if r.Outcome == "success" { return nil diff --git a/agent/agentcontainers/devcontainercli_test.go b/agent/agentcontainers/devcontainercli_test.go index 0f4b967db6ce9..dfe390ff7e6df 100644 --- a/agent/agentcontainers/devcontainercli_test.go +++ b/agent/agentcontainers/devcontainercli_test.go @@ -253,10 +253,10 @@ func TestDevcontainerCLI_ArgsAndParsing(t *testing.T) { logFile: "read-config-with-coder-customization.log", workspaceFolder: "/test/workspace", configPath: "", - wantArgs: "read-configuration --workspace-folder /test/workspace", + wantArgs: "read-configuration --include-merged-configuration --workspace-folder /test/workspace", wantError: false, wantConfig: agentcontainers.DevcontainerConfig{ - Configuration: agentcontainers.DevcontainerConfiguration{ + MergedConfiguration: agentcontainers.DevcontainerConfiguration{ Customizations: agentcontainers.DevcontainerCustomizations{ Coder: &agentcontainers.CoderCustomization{ DisplayApps: []codersdk.DisplayApp{ @@ -273,10 +273,10 @@ func TestDevcontainerCLI_ArgsAndParsing(t *testing.T) { logFile: "read-config-without-coder-customization.log", workspaceFolder: "/test/workspace", configPath: "/test/config.json", - wantArgs: "read-configuration --workspace-folder /test/workspace --config /test/config.json", + wantArgs: "read-configuration --include-merged-configuration --workspace-folder /test/workspace --config /test/config.json", wantError: false, wantConfig: agentcontainers.DevcontainerConfig{ - Configuration: agentcontainers.DevcontainerConfiguration{ + MergedConfiguration: agentcontainers.DevcontainerConfiguration{ Customizations: agentcontainers.DevcontainerCustomizations{ Coder: nil, }, @@ -288,7 +288,7 @@ func TestDevcontainerCLI_ArgsAndParsing(t *testing.T) { logFile: "read-config-error-not-found.log", workspaceFolder: "/nonexistent/workspace", configPath: "", - wantArgs: "read-configuration --workspace-folder /nonexistent/workspace", + wantArgs: "read-configuration --include-merged-configuration --workspace-folder /nonexistent/workspace", wantError: true, wantConfig: agentcontainers.DevcontainerConfig{}, }, diff --git a/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-with-coder-customization.log b/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-with-coder-customization.log index 4543fa3727542..fd052c50662e9 100644 --- a/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-with-coder-customization.log +++ b/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-with-coder-customization.log @@ -5,4 +5,4 @@ {"type":"stop","level":2,"timestamp":1749557820039,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder --filter label=devcontainer.config_file=/home/coder/coder/.devcontainer/devcontainer.json","startTimestamp":1749557820023} {"type":"start","level":2,"timestamp":1749557820039,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder"} {"type":"stop","level":2,"timestamp":1749557820054,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder","startTimestamp":1749557820039} -{"configuration":{"customizations":{"com.coder":{"displayApps":["vscode", "web_terminal"]}}}} +{"mergedConfiguration":{"customizations":{"coder":{"displayApps":["vscode", "web_terminal"]}}}} diff --git a/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-without-coder-customization.log b/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-without-coder-customization.log index 99d682e541ea3..98fc180cdd642 100644 --- a/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-without-coder-customization.log +++ b/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-without-coder-customization.log @@ -5,4 +5,4 @@ {"type":"stop","level":2,"timestamp":1749557820039,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder --filter label=devcontainer.config_file=/home/coder/coder/.devcontainer/devcontainer.json","startTimestamp":1749557820023} {"type":"start","level":2,"timestamp":1749557820039,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder"} {"type":"stop","level":2,"timestamp":1749557820054,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder","startTimestamp":1749557820039} -{"configuration":{"customizations":{}}} +{"mergedConfiguration":{"customizations":{}}} 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