From 22853697c7b6307d8b326cd5a4cebe5ed9439dc7 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 24 Jul 2026 18:03:43 -0500 Subject: [PATCH 1/2] fix(devcontainer): persist source override across workspace restarts The container image devcontainer override (--devcontainer image:) was passed only as a transient CLI option and never persisted to the workspace record. On restart the flag was absent, so the agent fell back to filesystem discovery and failed with "multiple devcontainer configurations found" when the repo contained more than one config. Persist DevContainerSource on the workspace and reuse it in getRawConfig when the CLI option is absent, so restarts reproduce the first-launch behavior. --- cmd/workspace/up/agent.go | 1 + cmd/workspace/up/up_client.go | 1 + pkg/devcontainer/config.go | 11 ++++++++--- pkg/devcontainer/config_test.go | 35 +++++++++++++++++++++++++++++++++ pkg/provider/workspace.go | 5 +++++ pkg/workspace/workspace.go | 5 +++++ pkg/workspace/workspace_test.go | 16 +++++++++++++++ 7 files changed, 71 insertions(+), 3 deletions(-) diff --git a/cmd/workspace/up/agent.go b/cmd/workspace/up/agent.go index 8a5525974f..7ad6989530 100644 --- a/cmd/workspace/up/agent.go +++ b/cmd/workspace/up/agent.go @@ -144,6 +144,7 @@ func (cmd *UpCmd) buildWorkspaceOptions(workspace *provider2.Workspace) provider baseOptions.ID = workspace.ID baseOptions.DevContainerPath = workspace.DevContainerPath baseOptions.DevContainerImage = workspace.DevContainerImage + baseOptions.DevContainerSource = workspace.DevContainerSource baseOptions.IDE = workspace.IDE.Name baseOptions.IDEOptions = nil baseOptions.Source = workspace.Source.String() diff --git a/cmd/workspace/up/up_client.go b/cmd/workspace/up/up_client.go index 9f0fcddc1c..ad720f8bc3 100644 --- a/cmd/workspace/up/up_client.go +++ b/cmd/workspace/up/up_client.go @@ -108,6 +108,7 @@ func (cmd *UpCmd) resolveParams( ReconfigureProvider: cmd.Reconfigure, DevContainerImage: cmd.DevContainerImage, DevContainerPath: cmd.DevContainerPath, + DevContainerSource: cmd.DevContainerSource, SSHConfigPath: cmd.SSHConfigPath, SSHConfigIncludePath: devsyConfig.ContextOption( config.ContextOptionSSHConfigIncludePath, diff --git a/pkg/devcontainer/config.go b/pkg/devcontainer/config.go index 8fbe49e220..607480ae2b 100644 --- a/pkg/devcontainer/config.go +++ b/pkg/devcontainer/config.go @@ -36,8 +36,12 @@ const ( // each supported source in order and falling back to an auto-detected default // when none applies. func (r *runner) getRawConfig(options provider.CLIOptions) (*config.DevContainerConfig, error) { - if options.DevContainerSource != "" { - return r.rawConfigFromSource(options) + source := options.DevContainerSource + if source == "" { + source = r.workspaceConfig.Workspace.DevContainerSource + } + if source != "" { + return r.rawConfigFromSource(source, options) } if conf := r.rawConfigFromWorkspace(); conf != nil { return conf, nil @@ -169,9 +173,10 @@ func workspaceMountFolderWarning(conf *config.DevContainerConfig) string { } func (r *runner) rawConfigFromSource( + source string, options provider.CLIOptions, ) (*config.DevContainerConfig, error) { - spec, err := ParseSourceSpec(options.DevContainerSource) + spec, err := ParseSourceSpec(source) if err != nil { return nil, err } diff --git a/pkg/devcontainer/config_test.go b/pkg/devcontainer/config_test.go index 8671d34ec3..37dbbcdea0 100644 --- a/pkg/devcontainer/config_test.go +++ b/pkg/devcontainer/config_test.go @@ -467,6 +467,41 @@ func TestGetRawConfig_SourceImageBypassesDiscovery(t *testing.T) { } } +func TestGetRawConfig_PersistedSourceImageBypassesDiscovery(t *testing.T) { + folder := t.TempDir() + seedAmbiguousProfiles(t, folder) + r := newRunnerAt(folder) + // Simulate a restart: the CLI option is absent, but the override was + // persisted on the workspace during the first up. + const image = "python" + r.workspaceConfig.Workspace.DevContainerSource = "image:" + image + + conf, err := r.getRawConfig(provider2.CLIOptions{}) + if err != nil { + t.Fatalf("getRawConfig: %v", err) + } + if conf.Image != image { + t.Errorf("Image = %q, want %q", conf.Image, image) + } + if len(conf.Features) != 0 { + t.Errorf("Features = %v, want none (project features must be ignored)", conf.Features) + } +} + +func TestGetRawConfig_CLISourceOverridesPersisted(t *testing.T) { + folder := t.TempDir() + r := newRunnerAt(folder) + r.workspaceConfig.Workspace.DevContainerSource = "image:persisted" + + conf, err := r.getRawConfig(provider2.CLIOptions{DevContainerSource: "image:cli"}) + if err != nil { + t.Fatalf("getRawConfig: %v", err) + } + if conf.Image != "cli" { + t.Errorf("Image = %q, want %q (CLI option must win over persisted)", conf.Image, "cli") + } +} + func TestGetRawConfig_SourceNoneWithImageBypassesDiscovery(t *testing.T) { folder := t.TempDir() seedAmbiguousProfiles(t, folder) diff --git a/pkg/provider/workspace.go b/pkg/provider/workspace.go index 46cebcb300..c1c2b7fc14 100644 --- a/pkg/provider/workspace.go +++ b/pkg/provider/workspace.go @@ -49,6 +49,11 @@ type Workspace struct { // DevContainerPath is the relative path where the devcontainer.json is located. DevContainerPath string `json:"devContainerPath,omitempty"` + // DevContainerSource is the devcontainer source override (e.g. "image:" + // or "none") that ignores the project's devcontainer.json. It is persisted so + // restarts reuse the same override instead of falling back to discovery. + DevContainerSource string `json:"devContainerSource,omitempty"` + // DevContainerConfig holds the config for the devcontainer.json. DevContainerConfig *devcontainerconfig.DevContainerConfig `json:"devContainerConfig,omitempty"` diff --git a/pkg/workspace/workspace.go b/pkg/workspace/workspace.go index 5346bf5712..f0804c6789 100644 --- a/pkg/workspace/workspace.go +++ b/pkg/workspace/workspace.go @@ -54,6 +54,7 @@ type ResolveParams struct { ReconfigureProvider bool DevContainerImage string DevContainerPath string + DevContainerSource string SSHConfigPath string SSHConfigIncludePath string Source *providerpkg.WorkspaceSource @@ -131,6 +132,10 @@ func applyDevContainerOverrides(workspace *providerpkg.Workspace, params Resolve workspace.DevContainerPath = params.DevContainerPath changed = true } + if params.DevContainerSource != "" && workspace.DevContainerSource != params.DevContainerSource { + workspace.DevContainerSource = params.DevContainerSource + changed = true + } if !changed && workspace.Source.Container == "" { return nil diff --git a/pkg/workspace/workspace_test.go b/pkg/workspace/workspace_test.go index 4566e5401b..b8042055e7 100644 --- a/pkg/workspace/workspace_test.go +++ b/pkg/workspace/workspace_test.go @@ -223,3 +223,19 @@ func TestApplyDevContainerOverrides_SetsImageAndPath(t *testing.T) { assert.Equal(t, testDevContainerImage, loaded.DevContainerImage) assert.Equal(t, testDevContainerPath, loaded.DevContainerPath) } + +func TestApplyDevContainerOverrides_PersistsSource(t *testing.T) { + setupTestPathManager(t) + + ws := &providerpkg.Workspace{ID: "ws-source", Context: testDefaultContext} + const source = "image:ghcr.io/example/image:latest" + err := applyDevContainerOverrides(ws, ResolveParams{DevContainerSource: source}) + require.NoError(t, err) + + assert.Equal(t, source, ws.DevContainerSource) + + // The source override must survive a restart, so it has to be persisted. + loaded, err := providerpkg.LoadWorkspaceConfig(testDefaultContext, ws.ID) + require.NoError(t, err) + assert.Equal(t, source, loaded.DevContainerSource) +} From 8ebfa8cc048b8c5b9fdaed6a82ac9d94d5222725 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 24 Jul 2026 18:29:01 -0500 Subject: [PATCH 2/2] style: wrap long line to satisfy golines --- pkg/workspace/workspace.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/workspace/workspace.go b/pkg/workspace/workspace.go index f0804c6789..db50452cf7 100644 --- a/pkg/workspace/workspace.go +++ b/pkg/workspace/workspace.go @@ -132,7 +132,8 @@ func applyDevContainerOverrides(workspace *providerpkg.Workspace, params Resolve workspace.DevContainerPath = params.DevContainerPath changed = true } - if params.DevContainerSource != "" && workspace.DevContainerSource != params.DevContainerSource { + if params.DevContainerSource != "" && + workspace.DevContainerSource != params.DevContainerSource { workspace.DevContainerSource = params.DevContainerSource changed = true }