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 8a5525974..7ad698953 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 9f0fcddc1..ad720f8bc 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 8fbe49e22..607480ae2 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 8671d34ec..37dbbcdea 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 46cebcb30..c1c2b7fc1 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 5346bf571..f0804c678 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 4566e5401..b8042055e 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 f0804c678..db50452cf 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 }