Files
coder/coderd/agentapi/manifest.go
T
Zach 72f35e1cd3 feat: runtime user secrets injection into workspaces (#24313)
Injects user secrets into workspace agents at runtime via the agent
manifest. Secrets with an environment variable name are set as
environment variables in every agent session and startup script. Secrets
with a file path are written to disk before startup scripts run.

- Fetch user secrets in GetManifest and convert to proto
- Defensively strip secrets from manifests received by the agent to
   avoid accidental leakage
- Add WorkspaceSecret type and proto conversion helpers to agentsdk
- Write secret files eagerly on manifest fetch (0600 perms, 0700 dirs)
- Inject secret env vars per-session in updateCommandEnv
- Expand ~/paths using caller-resolved home directory
- Log file write errors without blocking workspace startup
2026-04-17 16:55:24 -06:00

295 lines
9.8 KiB
Go

package agentapi
import (
"context"
"database/sql"
"errors"
"net/url"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
"google.golang.org/protobuf/types/known/durationpb"
"tailscale.com/tailcfg"
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/externalauth"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/tailnet"
)
type ManifestAPI struct {
AccessURL *url.URL
AppHostname string
ExternalAuthConfigs []*externalauth.Config
DisableDirectConnections bool
DerpForceWebSockets bool
WorkspaceID uuid.UUID
AgentFn func(ctx context.Context) (database.WorkspaceAgent, error)
Database database.Store
DerpMapFn func() *tailcfg.DERPMap
}
func (a *ManifestAPI) GetManifest(ctx context.Context, _ *agentproto.GetManifestRequest) (*agentproto.Manifest, error) {
var (
dbApps []database.WorkspaceApp
scripts []database.WorkspaceAgentScript
metadata []database.WorkspaceAgentMetadatum
workspace database.Workspace
devcontainers []database.WorkspaceAgentDevcontainer
)
workspaceAgent, err := a.AgentFn(ctx)
if err != nil {
return nil, xerrors.Errorf("getting workspace agent: %w", err)
}
var eg errgroup.Group
eg.Go(func() (err error) {
dbApps, err = a.Database.GetWorkspaceAppsByAgentID(ctx, workspaceAgent.ID)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
return err
}
return nil
})
eg.Go(func() (err error) {
// nolint:gocritic // This is necessary to fetch agent scripts!
scripts, err = a.Database.GetWorkspaceAgentScriptsByAgentIDs(dbauthz.AsSystemRestricted(ctx), []uuid.UUID{workspaceAgent.ID})
return err
})
eg.Go(func() (err error) {
metadata, err = a.Database.GetWorkspaceAgentMetadata(ctx, database.GetWorkspaceAgentMetadataParams{
WorkspaceAgentID: workspaceAgent.ID,
Keys: nil, // all
})
return err
})
eg.Go(func() (err error) {
workspace, err = a.Database.GetWorkspaceByID(ctx, a.WorkspaceID)
if err != nil {
return xerrors.Errorf("getting workspace by id: %w", err)
}
return err
})
eg.Go(func() (err error) {
devcontainers, err = a.Database.GetWorkspaceAgentDevcontainersByAgentID(ctx, workspaceAgent.ID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
return nil
})
err = eg.Wait()
if err != nil {
return nil, xerrors.Errorf("fetching workspace agent data: %w", err)
}
// Fetch user secrets for injection into the agent manifest.
// This runs after the errgroup because it needs workspace.OwnerID.
//nolint:gocritic // System context needed to read secrets for the workspace owner.
userSecrets, err := a.Database.ListUserSecretsWithValues(dbauthz.AsSystemRestricted(ctx), workspace.OwnerID)
if err != nil {
return nil, xerrors.Errorf("getting user secrets: %w", err)
}
appSlug := appurl.ApplicationURL{
AppSlugOrPort: "{{port}}",
AgentName: workspaceAgent.Name,
WorkspaceName: workspace.Name,
Username: workspace.OwnerUsername,
}
vscodeProxyURI := vscodeProxyURI(appSlug, a.AccessURL, a.AppHostname)
envs, err := db2sdk.WorkspaceAgentEnvironment(workspaceAgent)
if err != nil {
return nil, err
}
var gitAuthConfigs uint32
for _, cfg := range a.ExternalAuthConfigs {
if codersdk.EnhancedExternalAuthProvider(cfg.Type).Git() {
gitAuthConfigs++
}
}
apps, err := dbAppsToProto(dbApps, workspaceAgent, workspace.OwnerUsername, workspace, a.AppHostname)
if err != nil {
return nil, xerrors.Errorf("converting workspace apps: %w", err)
}
var parentID []byte
if workspaceAgent.ParentID.Valid {
parentID = workspaceAgent.ParentID.UUID[:]
}
return &agentproto.Manifest{
AgentId: workspaceAgent.ID[:],
AgentName: workspaceAgent.Name,
OwnerUsername: workspace.OwnerUsername,
WorkspaceId: workspace.ID[:],
WorkspaceName: workspace.Name,
GitAuthConfigs: gitAuthConfigs,
EnvironmentVariables: envs,
Directory: workspaceAgent.Directory,
VsCodePortProxyUri: vscodeProxyURI,
MotdPath: workspaceAgent.MOTDFile,
DisableDirectConnections: a.DisableDirectConnections,
DerpForceWebsockets: a.DerpForceWebSockets,
ParentId: parentID,
DerpMap: tailnet.DERPMapToProto(a.DerpMapFn()),
Scripts: dbAgentScriptsToProto(scripts),
Apps: apps,
Metadata: dbAgentMetadataToProtoDescription(metadata),
Devcontainers: dbAgentDevcontainersToProto(devcontainers),
Secrets: dbUserSecretsToProto(userSecrets),
}, nil
}
func vscodeProxyURI(app appurl.ApplicationURL, accessURL *url.URL, appHost string) string {
// Proxying by port only works for subdomains. If subdomain support is not
// available, return an empty string.
if appHost == "" {
return ""
}
// This will handle the ports from the accessURL or appHost.
appHost = appurl.SubdomainAppHost(appHost, accessURL)
// Return the url with a scheme and any wildcards replaced with the app slug.
return accessURL.Scheme + "://" + strings.ReplaceAll(appHost, "*", app.String())
}
func dbAgentMetadataToProtoDescription(metadata []database.WorkspaceAgentMetadatum) []*agentproto.WorkspaceAgentMetadata_Description {
ret := make([]*agentproto.WorkspaceAgentMetadata_Description, len(metadata))
for i, metadatum := range metadata {
ret[i] = dbAgentMetadatumToProtoDescription(metadatum)
}
return ret
}
func dbAgentMetadatumToProtoDescription(metadatum database.WorkspaceAgentMetadatum) *agentproto.WorkspaceAgentMetadata_Description {
return &agentproto.WorkspaceAgentMetadata_Description{
DisplayName: metadatum.DisplayName,
Key: metadatum.Key,
Script: metadatum.Script,
Interval: durationpb.New(time.Duration(metadatum.Interval)),
Timeout: durationpb.New(time.Duration(metadatum.Timeout)),
}
}
func dbAgentScriptsToProto(scripts []database.WorkspaceAgentScript) []*agentproto.WorkspaceAgentScript {
ret := make([]*agentproto.WorkspaceAgentScript, len(scripts))
for i, script := range scripts {
ret[i] = dbAgentScriptToProto(script)
}
return ret
}
func dbAgentScriptToProto(script database.WorkspaceAgentScript) *agentproto.WorkspaceAgentScript {
return &agentproto.WorkspaceAgentScript{
Id: script.ID[:],
LogSourceId: script.LogSourceID[:],
LogPath: script.LogPath,
Script: script.Script,
Cron: script.Cron,
RunOnStart: script.RunOnStart,
RunOnStop: script.RunOnStop,
StartBlocksLogin: script.StartBlocksLogin,
Timeout: durationpb.New(time.Duration(script.TimeoutSeconds) * time.Second),
}
}
func dbAppsToProto(dbApps []database.WorkspaceApp, agent database.WorkspaceAgent, ownerName string, workspace database.Workspace, appHostname string) ([]*agentproto.WorkspaceApp, error) {
ret := make([]*agentproto.WorkspaceApp, len(dbApps))
for i, dbApp := range dbApps {
var err error
ret[i], err = dbAppToProto(dbApp, agent, ownerName, workspace, appHostname)
if err != nil {
return nil, xerrors.Errorf("parse app %v (%q): %w", i, dbApp.Slug, err)
}
}
return ret, nil
}
func dbAppToProto(dbApp database.WorkspaceApp, agent database.WorkspaceAgent, ownerName string, workspace database.Workspace, appHostname string) (*agentproto.WorkspaceApp, error) {
sharingLevelRaw, ok := agentproto.WorkspaceApp_SharingLevel_value[strings.ToUpper(string(dbApp.SharingLevel))]
if !ok {
return nil, xerrors.Errorf("unknown app sharing level: %q", dbApp.SharingLevel)
}
healthRaw, ok := agentproto.WorkspaceApp_Health_value[strings.ToUpper(string(dbApp.Health))]
if !ok {
return nil, xerrors.Errorf("unknown app health: %q", dbApp.SharingLevel)
}
// SubdomainName should be empty if AppHostname is not configured
subdomainName := ""
if appHostname != "" {
subdomainName = db2sdk.AppSubdomain(dbApp, agent.Name, workspace.Name, ownerName)
}
return &agentproto.WorkspaceApp{
Id: dbApp.ID[:],
Url: dbApp.Url.String,
External: dbApp.External,
Slug: dbApp.Slug,
DisplayName: dbApp.DisplayName,
Command: dbApp.Command.String,
Icon: dbApp.Icon,
Subdomain: dbApp.Subdomain,
SubdomainName: subdomainName,
SharingLevel: agentproto.WorkspaceApp_SharingLevel(sharingLevelRaw),
Healthcheck: &agentproto.WorkspaceApp_Healthcheck{
Url: dbApp.HealthcheckUrl,
Interval: durationpb.New(time.Duration(dbApp.HealthcheckInterval) * time.Second),
Threshold: dbApp.HealthcheckThreshold,
},
Health: agentproto.WorkspaceApp_Health(healthRaw),
Hidden: dbApp.Hidden,
}, nil
}
func dbAgentDevcontainersToProto(devcontainers []database.WorkspaceAgentDevcontainer) []*agentproto.WorkspaceAgentDevcontainer {
ret := make([]*agentproto.WorkspaceAgentDevcontainer, len(devcontainers))
for i, dc := range devcontainers {
var subagentID []byte
if dc.SubagentID.Valid {
subagentID = dc.SubagentID.UUID[:]
}
ret[i] = &agentproto.WorkspaceAgentDevcontainer{
Id: dc.ID[:],
Name: dc.Name,
WorkspaceFolder: dc.WorkspaceFolder,
ConfigPath: dc.ConfigPath,
SubagentId: subagentID,
}
}
return ret
}
func dbUserSecretsToProto(secrets []database.UserSecret) []*agentproto.WorkspaceSecret {
ret := make([]*agentproto.WorkspaceSecret, 0, len(secrets))
for _, s := range secrets {
// Only include secrets that have an environment variable
// name or file path set. Secrets with neither are not
// injected at runtime.
if s.EnvName == "" && s.FilePath == "" {
continue
}
ret = append(ret, &agentproto.WorkspaceSecret{
EnvName: s.EnvName,
FilePath: s.FilePath,
Value: []byte(s.Value),
})
}
return ret
}