Skip to content

feat(agent/agentcontainers): support apps for dev container agents #18346

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 16 commits into from
Jun 18, 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
8 changes: 4 additions & 4 deletions agent/agentcontainers/acmock/acmock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 44 additions & 1 deletion agent/agentcontainers/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ type API struct {
subAgentURL string
subAgentEnv []string

ownerName string
workspaceName string

mu sync.RWMutex
closed bool
containers codersdk.WorkspaceAgentListContainersResponse // Output from the last list operation.
Expand Down Expand Up @@ -153,6 +156,15 @@ func WithSubAgentEnv(env ...string) Option {
}
}

// WithManifestInfo sets the owner name, and workspace name
// for the sub-agent.
func WithManifestInfo(owner, workspace string) Option {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the naming suggests you can pass in the entire manifest?

return func(api *API) {
api.ownerName = owner
api.workspaceName = workspace
}
}

// WithDevcontainers sets the known devcontainers for the API. This
// allows the API to be aware of devcontainers defined in the workspace
// agent manifest.
Expand Down Expand Up @@ -1127,7 +1139,16 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
codersdk.DisplayAppPortForward: true,
}

if config, err := api.dccli.ReadConfig(ctx, dc.WorkspaceFolder, dc.ConfigPath); err != nil {
var appsWithPossibleDuplicates []SubAgentApp

if config, err := api.dccli.ReadConfig(ctx, dc.WorkspaceFolder, dc.ConfigPath,
[]string{
fmt.Sprintf("CODER_WORKSPACE_AGENT_NAME=%s", dc.Name),
fmt.Sprintf("CODER_WORKSPACE_OWNER_NAME=%s", api.ownerName),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we modify agent/agent.go to set this on the parent as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to ensure I know what you're referring to:

In this stanza here?

coder/agent/agent.go

Lines 1293 to 1309 in 529fb50

// Define environment variables that should be set for all commands,
// and then merge them with the current environment.
envs := map[string]string{
// Set env vars indicating we're inside a Coder workspace.
"CODER": "true",
"CODER_WORKSPACE_NAME": manifest.WorkspaceName,
"CODER_WORKSPACE_AGENT_NAME": manifest.AgentName,
// Specific Coder subcommands require the agent token exposed!
"CODER_AGENT_TOKEN": *a.sessionToken.Load(),
// Git on Windows resolves with UNIX-style paths.
// If using backslashes, it's unable to find the executable.
"GIT_SSH_COMMAND": fmt.Sprintf("%s gitssh --", unixExecutablePath),
// Hide Coder message on code-server's "Getting Started" page
"CS_DISABLE_GETTING_STARTED_OVERRIDE": "true",
}

fmt.Sprintf("CODER_WORKSPACE_NAME=%s", api.workspaceName),
fmt.Sprintf("CODER_URL=%s", api.subAgentURL),
Comment on lines +1146 to +1149
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note for later: we'll need to document these environment variables somewhere.

},
); err != nil {
api.logger.Error(ctx, "unable to read devcontainer config", slog.Error(err))
} else {
coderCustomization := config.MergedConfiguration.Customizations.Coder
Expand All @@ -1143,6 +1164,8 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
}
displayAppsMap[app] = enabled
}

appsWithPossibleDuplicates = append(appsWithPossibleDuplicates, customization.Apps...)
}
}

Expand All @@ -1154,7 +1177,27 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
}
slices.Sort(displayApps)

appSlugs := make(map[string]struct{})
apps := make([]SubAgentApp, 0, len(appsWithPossibleDuplicates))

// We want to deduplicate the apps based on their slugs here.
// As we want to prioritize later apps, we will walk through this
// backwards.
for _, app := range slices.Backward(appsWithPossibleDuplicates) {
if _, slugAlreadyExists := appSlugs[app.Slug]; slugAlreadyExists {
continue
}

appSlugs[app.Slug] = struct{}{}
apps = append(apps, app)
}

// Apps is currently in reverse order here, so by reversing it we restore
// it to the original order.
slices.Reverse(apps)

subAgentConfig.DisplayApps = displayApps
subAgentConfig.Apps = apps
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should log app creation errors from the response after agent creation too.

}

deleteSubAgent := proc.agent.ID != uuid.Nil && maybeRecreateSubAgent && !proc.agent.EqualConfig(subAgentConfig)
Expand Down
137 changes: 132 additions & 5 deletions agent/agentcontainers/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ type fakeDevcontainerCLI struct {
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
readConfigErrC chan func(envs []string) error
}

func (f *fakeDevcontainerCLI) Up(ctx context.Context, _, _ string, _ ...agentcontainers.DevcontainerCLIUpOptions) (string, error) {
Expand Down Expand Up @@ -99,14 +99,14 @@ 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) {
func (f *fakeDevcontainerCLI) ReadConfig(ctx context.Context, _, _ string, envs []string, _ ...agentcontainers.DevcontainerCLIReadConfigOptions) (agentcontainers.DevcontainerConfig, error) {
if f.readConfigErrC != nil {
select {
case <-ctx.Done():
return agentcontainers.DevcontainerConfig{}, ctx.Err()
case err, ok := <-f.readConfigErrC:
case fn, ok := <-f.readConfigErrC:
if ok {
return f.readConfig, err
return f.readConfig, fn(envs)
}
}
}
Expand Down Expand Up @@ -1253,7 +1253,8 @@ func TestAPI(t *testing.T) {
deleteErrC: make(chan error, 1),
}
fakeDCCLI = &fakeDevcontainerCLI{
execErrC: make(chan func(cmd string, args ...string) error, 1),
execErrC: make(chan func(cmd string, args ...string) error, 1),
readConfigErrC: make(chan func(envs []string) error, 1),
}

testContainer = codersdk.WorkspaceAgentContainer{
Expand Down Expand Up @@ -1293,13 +1294,15 @@ func TestAPI(t *testing.T) {
agentcontainers.WithSubAgentClient(fakeSAC),
agentcontainers.WithSubAgentURL("test-subagent-url"),
agentcontainers.WithDevcontainerCLI(fakeDCCLI),
agentcontainers.WithManifestInfo("test-user", "test-workspace"),
)
apiClose := func() {
closeOnce.Do(func() {
// Close before api.Close() defer to avoid deadlock after test.
close(fakeSAC.createErrC)
close(fakeSAC.deleteErrC)
close(fakeDCCLI.execErrC)
close(fakeDCCLI.readConfigErrC)

_ = api.Close()
})
Expand All @@ -1313,6 +1316,13 @@ func TestAPI(t *testing.T) {
assert.Empty(t, args)
return nil
}) // Exec pwd.
testutil.RequireSend(ctx, t, fakeDCCLI.readConfigErrC, func(envs []string) error {
assert.Contains(t, envs, "CODER_WORKSPACE_AGENT_NAME=test-container")
assert.Contains(t, envs, "CODER_WORKSPACE_NAME=test-workspace")
assert.Contains(t, envs, "CODER_WORKSPACE_OWNER_NAME=test-user")
assert.Contains(t, envs, "CODER_URL=test-subagent-url")
return nil
})

// Make sure the ticker function has been registered
// before advancing the clock.
Expand Down Expand Up @@ -1453,6 +1463,13 @@ func TestAPI(t *testing.T) {
assert.Empty(t, args)
return nil
}) // Exec pwd.
testutil.RequireSend(ctx, t, fakeDCCLI.readConfigErrC, func(envs []string) error {
assert.Contains(t, envs, "CODER_WORKSPACE_AGENT_NAME=test-container")
assert.Contains(t, envs, "CODER_WORKSPACE_NAME=test-workspace")
assert.Contains(t, envs, "CODER_WORKSPACE_OWNER_NAME=test-user")
assert.Contains(t, envs, "CODER_URL=test-subagent-url")
return nil
})

err = api.RefreshContainers(ctx)
require.NoError(t, err, "refresh containers should not fail")
Expand Down Expand Up @@ -1603,6 +1620,116 @@ func TestAPI(t *testing.T) {
assert.Contains(t, subAgent.DisplayApps, codersdk.DisplayAppPortForward)
},
},
{
name: "WithApps",
customization: []agentcontainers.CoderCustomization{
{
Apps: []agentcontainers.SubAgentApp{
{
Slug: "web-app",
DisplayName: "Web Application",
URL: "http://localhost:8080",
OpenIn: codersdk.WorkspaceAppOpenInTab,
Share: codersdk.WorkspaceAppSharingLevelOwner,
Icon: "/icons/web.svg",
Order: int32(1),
},
{
Slug: "api-server",
DisplayName: "API Server",
URL: "http://localhost:3000",
OpenIn: codersdk.WorkspaceAppOpenInSlimWindow,
Share: codersdk.WorkspaceAppSharingLevelAuthenticated,
Icon: "/icons/api.svg",
Order: int32(2),
Hidden: true,
},
{
Slug: "docs",
DisplayName: "Documentation",
URL: "http://localhost:4000",
OpenIn: codersdk.WorkspaceAppOpenInTab,
Share: codersdk.WorkspaceAppSharingLevelPublic,
Icon: "/icons/book.svg",
Order: int32(3),
},
},
},
},
afterCreate: func(t *testing.T, subAgent agentcontainers.SubAgent) {
require.Len(t, subAgent.Apps, 3)

// Verify first app
assert.Equal(t, "web-app", subAgent.Apps[0].Slug)
assert.Equal(t, "Web Application", subAgent.Apps[0].DisplayName)
assert.Equal(t, "http://localhost:8080", subAgent.Apps[0].URL)
assert.Equal(t, codersdk.WorkspaceAppOpenInTab, subAgent.Apps[0].OpenIn)
assert.Equal(t, codersdk.WorkspaceAppSharingLevelOwner, subAgent.Apps[0].Share)
assert.Equal(t, "/icons/web.svg", subAgent.Apps[0].Icon)
assert.Equal(t, int32(1), subAgent.Apps[0].Order)

// Verify second app
assert.Equal(t, "api-server", subAgent.Apps[1].Slug)
assert.Equal(t, "API Server", subAgent.Apps[1].DisplayName)
assert.Equal(t, "http://localhost:3000", subAgent.Apps[1].URL)
assert.Equal(t, codersdk.WorkspaceAppOpenInSlimWindow, subAgent.Apps[1].OpenIn)
assert.Equal(t, codersdk.WorkspaceAppSharingLevelAuthenticated, subAgent.Apps[1].Share)
assert.Equal(t, "/icons/api.svg", subAgent.Apps[1].Icon)
assert.Equal(t, int32(2), subAgent.Apps[1].Order)
assert.Equal(t, true, subAgent.Apps[1].Hidden)

// Verify third app
assert.Equal(t, "docs", subAgent.Apps[2].Slug)
assert.Equal(t, "Documentation", subAgent.Apps[2].DisplayName)
assert.Equal(t, "http://localhost:4000", subAgent.Apps[2].URL)
assert.Equal(t, codersdk.WorkspaceAppOpenInTab, subAgent.Apps[2].OpenIn)
assert.Equal(t, codersdk.WorkspaceAppSharingLevelPublic, subAgent.Apps[2].Share)
assert.Equal(t, "/icons/book.svg", subAgent.Apps[2].Icon)
assert.Equal(t, int32(3), subAgent.Apps[2].Order)
},
},
{
name: "AppDeduplication",
customization: []agentcontainers.CoderCustomization{
{
Apps: []agentcontainers.SubAgentApp{
{
Slug: "foo-app",
Hidden: true,
Order: 1,
},
{
Slug: "bar-app",
},
},
},
{
Apps: []agentcontainers.SubAgentApp{
{
Slug: "foo-app",
Order: 2,
},
{
Slug: "baz-app",
},
},
},
},
afterCreate: func(t *testing.T, subAgent agentcontainers.SubAgent) {
require.Len(t, subAgent.Apps, 3)

// As the original "foo-app" gets overridden by the later "foo-app",
// we expect "bar-app" to be first in the order.
assert.Equal(t, "bar-app", subAgent.Apps[0].Slug)
assert.Equal(t, "foo-app", subAgent.Apps[1].Slug)
assert.Equal(t, "baz-app", subAgent.Apps[2].Slug)

// We do not expect the properties from the original "foo-app" to be
// carried over.
assert.Equal(t, false, subAgent.Apps[1].Hidden)
assert.Equal(t, int32(2), subAgent.Apps[1].Order)
},
},
}

for _, tt := range tests {
Expand Down
12 changes: 8 additions & 4 deletions agent/agentcontainers/devcontainercli.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"io"
"os"

"golang.org/x/xerrors"

Expand All @@ -32,13 +33,14 @@ type DevcontainerCustomizations struct {

type CoderCustomization struct {
DisplayApps map[codersdk.DisplayApp]bool `json:"displayApps,omitempty"`
Apps []SubAgentApp `json:"apps,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)
ReadConfig(ctx context.Context, workspaceFolder, configPath string, env []string, opts ...DevcontainerCLIReadConfigOptions) (DevcontainerConfig, error)
}

// DevcontainerCLIUpOptions are options for the devcontainer CLI Up
Expand Down Expand Up @@ -113,8 +115,8 @@ type devcontainerCLIReadConfigConfig struct {
stderr io.Writer
}

// WithExecOutput sets additional stdout and stderr writers for logs
// during Exec operations.
// WithReadConfigOutput sets additional stdout and stderr writers for logs
// during ReadConfig operations.
func WithReadConfigOutput(stdout, stderr io.Writer) DevcontainerCLIReadConfigOptions {
return func(o *devcontainerCLIReadConfigConfig) {
o.stdout = stdout
Expand Down Expand Up @@ -250,7 +252,7 @@ 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) {
func (d *devcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, configPath string, env []string, opts ...DevcontainerCLIReadConfigOptions) (DevcontainerConfig, error) {
conf := applyDevcontainerCLIReadConfigOptions(opts)
logger := d.logger.With(slog.F("workspace_folder", workspaceFolder), slog.F("config_path", configPath))

Expand All @@ -263,6 +265,8 @@ 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
stdoutWriters := []io.Writer{&stdoutBuf, &devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stdout", true))}}
Expand Down
2 changes: 1 addition & 1 deletion agent/agentcontainers/devcontainercli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ func TestDevcontainerCLI_ArgsAndParsing(t *testing.T) {
}

dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer)
config, err := dccli.ReadConfig(ctx, tt.workspaceFolder, tt.configPath, tt.opts...)
config, err := dccli.ReadConfig(ctx, tt.workspaceFolder, tt.configPath, []string{}, tt.opts...)
if tt.wantError {
assert.Error(t, err, "want error")
assert.Equal(t, agentcontainers.DevcontainerConfig{}, config, "expected empty config on error")
Expand Down
Loading
Loading