diff --git a/workspace-server/internal/provisioner/provisioner.go b/workspace-server/internal/provisioner/provisioner.go index 2d16c010b..e97b0ba3e 100644 --- a/workspace-server/internal/provisioner/provisioner.go +++ b/workspace-server/internal/provisioner/provisioner.go @@ -98,6 +98,12 @@ const ( // dependency into the provisioner. Kept in sync with models.KindPlatform // (a provisioner test asserts the two agree). WorkspaceKindPlatform = "platform" + + // alpineImage is the digest-pinned alpine image used for throwaway + // containers that read/write volumes or migrate data. Pinning prevents + // supply-chain drift / compromised-tag attacks (core#2545). + // Matches workspace-server/Dockerfile FROM alpine:3.20@sha256:... + alpineImageDefault = "alpine:3.20@sha256:c64c687cbea9300178b30c95835354e34c4e4febc4badfe27102879de0483b5e" ) // WorkspaceConfig holds the parameters needed to provision a workspace container. @@ -252,7 +258,8 @@ type dockerClient interface { // Provisioner manages Docker containers for workspace agents. type Provisioner struct { - cli dockerClient + cli dockerClient + alpineImage string // overridable in tests; production uses the digest-pinned default } // New creates a new Provisioner connected to the local Docker daemon. @@ -261,7 +268,7 @@ func New() (*Provisioner, error) { if err != nil { return nil, fmt.Errorf("failed to connect to Docker: %w", err) } - return &Provisioner{cli: cli}, nil + return &Provisioner{cli: cli, alpineImage: alpineImageDefault}, nil } // ContainerName returns the Docker container name for a workspace. @@ -1262,7 +1269,7 @@ func (p *Provisioner) ExecRead(ctx context.Context, containerName, filePath stri // Used as a fallback when ExecRead fails (container already stopped). func (p *Provisioner) ReadFromVolume(ctx context.Context, volumeName, filePath string) ([]byte, error) { resp, err := p.cli.ContainerCreate(ctx, &container.Config{ - Image: "alpine", + Image: p.alpineImage, Cmd: []string{"cat", "/vol/" + filePath}, }, &container.HostConfig{ Binds: []string{volumeName + ":/vol:ro"}, @@ -1333,7 +1340,7 @@ func (p *Provisioner) WriteAuthTokenToVolume(ctx context.Context, workspaceID, t } volName := p.resolveConfigVolumeName(ctx, workspaceID) resp, err := p.cli.ContainerCreate(ctx, &container.Config{ - Image: "alpine", + Image: p.alpineImage, Cmd: []string{"sh", "-c", writeAuthTokenVolumeCmd()}, Env: []string{"TOKEN=" + token}, }, &container.HostConfig{ @@ -1417,9 +1424,12 @@ func (p *Provisioner) migrateVolumeIfNeeded(ctx context.Context, newName, legacy } // Copy data from legacy to new via a short-lived alpine container. + // The trailing test guards against silent empty copies (e.g. legacy + // volume unexpectedly bare) which would leave the workspace without + // its config on restart. Core#2545. resp, err := p.cli.ContainerCreate(ctx, &container.Config{ - Image: "alpine", - Cmd: []string{"sh", "-c", "cp -a /legacy/. /new/"}, + Image: p.alpineImage, + Cmd: []string{"sh", "-c", "cp -a /legacy/. /new/ && test -n \"$(ls -A /new/)\""}, }, &container.HostConfig{ Binds: []string{ legacyName + ":/legacy", @@ -1765,7 +1775,7 @@ func (p *Provisioner) VolumeHasFile(ctx context.Context, workspaceID, relPath st return false, nil } resp, err := p.cli.ContainerCreate(ctx, &container.Config{ - Image: "alpine", + Image: p.alpineImage, Cmd: []string{"test", "-f", "/vol/" + relPath}, }, &container.HostConfig{ Binds: []string{volName + ":/vol:ro"}, diff --git a/workspace-server/internal/provisioner/provisioner_migrate_test.go b/workspace-server/internal/provisioner/provisioner_migrate_test.go index cb2423b88..148c4ebff 100644 --- a/workspace-server/internal/provisioner/provisioner_migrate_test.go +++ b/workspace-server/internal/provisioner/provisioner_migrate_test.go @@ -181,7 +181,7 @@ const migrateTestWorkspaceID = "abcdef1234567890" func TestResolveConfigVolumeName_LegacyExists_MigratesInPlace(t *testing.T) { ctx := context.Background() cli := newFakeDockerClient() - p := &Provisioner{cli: cli} + p := &Provisioner{cli: cli, alpineImage: "alpine"} newName := ConfigVolumeName(migrateTestWorkspaceID) legacyName := legacyConfigVolumeName(migrateTestWorkspaceID) @@ -234,7 +234,7 @@ func TestResolveConfigVolumeName_LegacyExists_MigratesInPlace(t *testing.T) { func TestResolveConfigVolumeName_LegacyAbsent_NoMigration(t *testing.T) { ctx := context.Background() cli := newFakeDockerClient() - p := &Provisioner{cli: cli} + p := &Provisioner{cli: cli, alpineImage: "alpine"} newName := ConfigVolumeName(migrateTestWorkspaceID) legacyName := legacyConfigVolumeName(migrateTestWorkspaceID) @@ -259,7 +259,7 @@ func TestResolveConfigVolumeName_LegacyAbsent_NoMigration(t *testing.T) { func TestResolveClaudeSessionVolumeName_LegacyExists_MigratesInPlace(t *testing.T) { ctx := context.Background() cli := newFakeDockerClient() - p := &Provisioner{cli: cli} + p := &Provisioner{cli: cli, alpineImage: "alpine"} newName := ClaudeSessionVolumeName(migrateTestWorkspaceID) legacyName := legacyClaudeSessionVolumeName(migrateTestWorkspaceID) @@ -288,7 +288,7 @@ func TestResolveClaudeSessionVolumeName_LegacyExists_MigratesInPlace(t *testing. func TestResolveClaudeSessionVolumeName_LegacyAbsent_NoMigration(t *testing.T) { ctx := context.Background() cli := newFakeDockerClient() - p := &Provisioner{cli: cli} + p := &Provisioner{cli: cli, alpineImage: "alpine"} newName := ClaudeSessionVolumeName(migrateTestWorkspaceID) legacyName := legacyClaudeSessionVolumeName(migrateTestWorkspaceID) @@ -308,7 +308,7 @@ func TestResolveClaudeSessionVolumeName_LegacyAbsent_NoMigration(t *testing.T) { func TestMigrateVolumeIfNeeded_CopyFails_PreservesLegacy(t *testing.T) { ctx := context.Background() cli := newFakeDockerClient() - p := &Provisioner{cli: cli} + p := &Provisioner{cli: cli, alpineImage: "alpine"} newName := ConfigVolumeName(migrateTestWorkspaceID) legacyName := legacyConfigVolumeName(migrateTestWorkspaceID) @@ -328,7 +328,7 @@ func TestMigrateVolumeIfNeeded_CopyFails_PreservesLegacy(t *testing.T) { func TestStop_FullIDAbsent_LegacyRemoved(t *testing.T) { ctx := context.Background() cli := newFakeDockerClient() - p := &Provisioner{cli: cli} + p := &Provisioner{cli: cli, alpineImage: "alpine"} newName := ContainerName(migrateTestWorkspaceID) legacyName := legacyContainerName(migrateTestWorkspaceID) @@ -355,7 +355,7 @@ func TestStop_FullIDAbsent_LegacyRemoved(t *testing.T) { func TestStop_BothAbsent_IsNoOp(t *testing.T) { ctx := context.Background() cli := newFakeDockerClient() - p := &Provisioner{cli: cli} + p := &Provisioner{cli: cli, alpineImage: "alpine"} newName := ContainerName(migrateTestWorkspaceID) legacyName := legacyContainerName(migrateTestWorkspaceID) diff --git a/workspace-server/internal/provisioner/provisioner_test.go b/workspace-server/internal/provisioner/provisioner_test.go index 12f671f6f..6706859c1 100644 --- a/workspace-server/internal/provisioner/provisioner_test.go +++ b/workspace-server/internal/provisioner/provisioner_test.go @@ -1443,7 +1443,7 @@ func TestMigrateVolumeIfNeeded_ExistingTruncatedVolume(t *testing.T) { t.Skip("docker daemon unreachable:", pingErr) } - p := &Provisioner{cli: cli} + p := &Provisioner{cli: cli, alpineImage: "alpine"} workspaceID := "test-migrate-" + strconv.FormatInt(time.Now().UnixNano(), 10) legacyName := legacyConfigVolumeName(workspaceID) newName := ConfigVolumeName(workspaceID)