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
This commit is contained in:
Zach
2026-04-17 16:55:24 -06:00
committed by GitHub
parent 8e2343f59c
commit 72f35e1cd3
9 changed files with 685 additions and 4 deletions
+27
View File
@@ -90,6 +90,14 @@ func (a *ManifestAPI) GetManifest(ctx context.Context, _ *agentproto.GetManifest
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,
@@ -141,6 +149,7 @@ func (a *ManifestAPI) GetManifest(ctx context.Context, _ *agentproto.GetManifest
Apps: apps,
Metadata: dbAgentMetadataToProtoDescription(metadata),
Devcontainers: dbAgentDevcontainersToProto(devcontainers),
Secrets: dbUserSecretsToProto(userSecrets),
}, nil
}
@@ -265,3 +274,21 @@ func dbAgentDevcontainersToProto(devcontainers []database.WorkspaceAgentDevconta
}
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
}
+65
View File
@@ -336,6 +336,7 @@ func TestGetManifest(t *testing.T) {
}).Return(metadata, nil)
mDB.EXPECT().GetWorkspaceAgentDevcontainersByAgentID(gomock.Any(), agent.ID).Return(devcontainers, nil)
mDB.EXPECT().GetWorkspaceByID(gomock.Any(), workspace.ID).Return(workspace, nil)
mDB.EXPECT().ListUserSecretsWithValues(gomock.Any(), workspace.OwnerID).Return(nil, nil)
got, err := api.GetManifest(context.Background(), &agentproto.GetManifestRequest{})
require.NoError(t, err)
@@ -362,6 +363,7 @@ func TestGetManifest(t *testing.T) {
Apps: protoApps,
Metadata: protoMetadata,
Devcontainers: protoDevcontainers,
Secrets: []*agentproto.WorkspaceSecret{},
}
// Log got and expected with spew.
@@ -401,6 +403,7 @@ func TestGetManifest(t *testing.T) {
}).Return([]database.WorkspaceAgentMetadatum{}, nil)
mDB.EXPECT().GetWorkspaceAgentDevcontainersByAgentID(gomock.Any(), childAgent.ID).Return([]database.WorkspaceAgentDevcontainer{}, nil)
mDB.EXPECT().GetWorkspaceByID(gomock.Any(), workspace.ID).Return(workspace, nil)
mDB.EXPECT().ListUserSecretsWithValues(gomock.Any(), workspace.OwnerID).Return(nil, nil)
got, err := api.GetManifest(context.Background(), &agentproto.GetManifestRequest{})
require.NoError(t, err)
@@ -427,11 +430,71 @@ func TestGetManifest(t *testing.T) {
Apps: []*agentproto.WorkspaceApp{},
Metadata: []*agentproto.WorkspaceAgentMetadata_Description{},
Devcontainers: []*agentproto.WorkspaceAgentDevcontainer{},
Secrets: []*agentproto.WorkspaceSecret{},
}
require.Equal(t, expected, got)
})
t.Run("SecretsFiltering", func(t *testing.T) {
t.Parallel()
mDB := dbmock.NewMockStore(gomock.NewController(t))
api := &agentapi.ManifestAPI{
AccessURL: &url.URL{Scheme: "https", Host: "example.com"},
AppHostname: "*--apps.example.com",
ExternalAuthConfigs: []*externalauth.Config{
{Type: string(codersdk.EnhancedExternalAuthProviderGitHub)},
{Type: "some-provider"},
{Type: string(codersdk.EnhancedExternalAuthProviderGitLab)},
},
DisableDirectConnections: true,
DerpForceWebSockets: true,
AgentFn: func(ctx context.Context) (database.WorkspaceAgent, error) { return childAgent, nil },
WorkspaceID: workspace.ID,
Database: mDB,
DerpMapFn: derpMapFn,
}
mDB.EXPECT().GetWorkspaceAppsByAgentID(gomock.Any(), childAgent.ID).Return([]database.WorkspaceApp{}, nil)
mDB.EXPECT().GetWorkspaceAgentScriptsByAgentIDs(gomock.Any(), []uuid.UUID{childAgent.ID}).Return([]database.WorkspaceAgentScript{}, nil)
mDB.EXPECT().GetWorkspaceAgentMetadata(gomock.Any(), database.GetWorkspaceAgentMetadataParams{
WorkspaceAgentID: childAgent.ID,
Keys: nil,
}).Return([]database.WorkspaceAgentMetadatum{}, nil)
mDB.EXPECT().GetWorkspaceAgentDevcontainersByAgentID(gomock.Any(), childAgent.ID).Return([]database.WorkspaceAgentDevcontainer{}, nil)
mDB.EXPECT().GetWorkspaceByID(gomock.Any(), workspace.ID).Return(workspace, nil)
// Return a mix of secrets: env-only, file-only, both, and
// one with neither set. The last should be filtered out.
mDB.EXPECT().ListUserSecretsWithValues(gomock.Any(), workspace.OwnerID).Return([]database.UserSecret{
{EnvName: "GITHUB_TOKEN", FilePath: "", Value: "ghp_xxxx"},
{EnvName: "", FilePath: "~/.ssh/id_rsa", Value: "private-key"},
{EnvName: "BOTH_ENV", FilePath: "/etc/both", Value: "both-val"},
{EnvName: "", FilePath: "", Value: "stored-only"},
}, nil)
got, err := api.GetManifest(context.Background(), &agentproto.GetManifestRequest{})
require.NoError(t, err)
// The secret with neither env_name nor file_path should
// be filtered out, leaving exactly 3.
require.Len(t, got.Secrets, 3)
require.Equal(t, "GITHUB_TOKEN", got.Secrets[0].EnvName)
require.Equal(t, "", got.Secrets[0].FilePath)
require.Equal(t, []byte("ghp_xxxx"), got.Secrets[0].Value)
require.Equal(t, "", got.Secrets[1].EnvName)
require.Equal(t, "~/.ssh/id_rsa", got.Secrets[1].FilePath)
require.Equal(t, []byte("private-key"), got.Secrets[1].Value)
require.Equal(t, "BOTH_ENV", got.Secrets[2].EnvName)
require.Equal(t, "/etc/both", got.Secrets[2].FilePath)
require.Equal(t, []byte("both-val"), got.Secrets[2].Value)
})
t.Run("NoAppHostname", func(t *testing.T) {
t.Parallel()
@@ -522,6 +585,7 @@ func TestGetManifest(t *testing.T) {
}).Return(metadata, nil)
mDB.EXPECT().GetWorkspaceAgentDevcontainersByAgentID(gomock.Any(), agent.ID).Return(devcontainers, nil)
mDB.EXPECT().GetWorkspaceByID(gomock.Any(), workspace.ID).Return(workspace, nil)
mDB.EXPECT().ListUserSecretsWithValues(gomock.Any(), workspace.OwnerID).Return(nil, nil)
got, err := api.GetManifest(context.Background(), &agentproto.GetManifestRequest{})
require.NoError(t, err)
@@ -547,6 +611,7 @@ func TestGetManifest(t *testing.T) {
Apps: protoApps,
Metadata: protoMetadata,
Devcontainers: protoDevcontainers,
Secrets: []*agentproto.WorkspaceSecret{},
}
// Log got and expected with spew.