From 2abdb247ce1ce47c52be51fe7e46f77c21cb1500 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 7 May 2026 00:09:40 -0500 Subject: [PATCH 1/4] feat(config): resolve variable substitution in extends paths Apply local-scope variable substitution (localEnv, localWorkspaceFolder, localWorkspaceFolderBasename) to extends ref strings before resolving them. This enables using environment variables and workspace paths in devcontainer.json extends references, matching the devcontainer spec's variable substitution behavior. --- pkg/devcontainer/config/extends_test.go | 111 ++++++++++++++++++++++++ pkg/devcontainer/config/parse.go | 34 ++++++++ 2 files changed, 145 insertions(+) diff --git a/pkg/devcontainer/config/extends_test.go b/pkg/devcontainer/config/extends_test.go index 18e89113f..8bda82080 100644 --- a/pkg/devcontainer/config/extends_test.go +++ b/pkg/devcontainer/config/extends_test.go @@ -678,6 +678,117 @@ func TestExtendsRef_MarshalJSON(t *testing.T) { } } +func TestExtends_LocalEnvInPath(t *testing.T) { + tmpDir := t.TempDir() + writeJSON(t, tmpDir, "parent.json", `{ + "name": "parent", + "image": "ubuntu:20.04" + }`) + + childDir := filepath.Join(tmpDir, "child") + // #nosec G301 -- test directory + if err := os.MkdirAll(childDir, 0o750); err != nil { + t.Fatal(err) + } + + t.Setenv("TEST_EXTENDS_DIR", tmpDir) + writeJSON(t, childDir, "child.json", `{ + "extends": "${localEnv:TEST_EXTENDS_DIR}/parent.json", + "name": "child" + }`) + + cfg, err := ParseDevContainerJSONFile(filepath.Join(childDir, "child.json")) + if err != nil { + t.Fatal(err) + } + if cfg.Name != testNameChild { + t.Errorf("expected name 'child', got %q", cfg.Name) + } + if cfg.Image != testImageUbuntu { + t.Errorf("expected image 'ubuntu:20.04', got %q", cfg.Image) + } +} + +func TestExtends_LocalWorkspaceFolderInPath(t *testing.T) { + tmpDir := t.TempDir() + + configsDir := filepath.Join(tmpDir, "configs") + // #nosec G301 -- test directory + if err := os.MkdirAll(configsDir, 0o750); err != nil { + t.Fatal(err) + } + writeJSON(t, configsDir, "parent.json", `{ + "name": "parent", + "image": "node:18" + }`) + + writeJSON(t, tmpDir, "child.json", `{ + "extends": "${localWorkspaceFolder}/configs/parent.json", + "name": "child" + }`) + + cfg, err := ParseDevContainerJSONFile(filepath.Join(tmpDir, "child.json")) + if err != nil { + t.Fatal(err) + } + if cfg.Name != testNameChild { + t.Errorf("expected name 'child', got %q", cfg.Name) + } + if cfg.Image != "node:18" { + t.Errorf("expected image 'node:18', got %q", cfg.Image) + } +} + +func TestExtends_MissingEnvResolvesToEmpty(t *testing.T) { + tmpDir := t.TempDir() + writeJSON(t, tmpDir, "child.json", `{ + "extends": "${localEnv:NONEXISTENT_VAR_FOR_TEST}/parent.json", + "name": "child" + }`) + + _, err := ParseDevContainerJSONFile(filepath.Join(tmpDir, "child.json")) + if err == nil { + t.Fatal("expected error due to invalid path from empty env var") + } + if strings.Contains(err.Error(), "${localEnv:") { + t.Errorf( + "variable should have been substituted (resolved to empty), but got literal: %v", + err, + ) + } +} + +func TestExtends_LocalWorkspaceFolderBasenameInPath(t *testing.T) { + tmpDir := t.TempDir() + basename := filepath.Base(tmpDir) + + parentDir := filepath.Join(tmpDir, basename) + // #nosec G301 -- test directory + if err := os.MkdirAll(parentDir, 0o750); err != nil { + t.Fatal(err) + } + writeJSON(t, parentDir, "parent.json", `{ + "name": "parent", + "image": "alpine:3" + }`) + + writeJSON(t, tmpDir, "child.json", `{ + "extends": "${localWorkspaceFolder}/${localWorkspaceFolderBasename}/parent.json", + "name": "child" + }`) + + cfg, err := ParseDevContainerJSONFile(filepath.Join(tmpDir, "child.json")) + if err != nil { + t.Fatal(err) + } + if cfg.Name != testNameChild { + t.Errorf("expected name 'child', got %q", cfg.Name) + } + if cfg.Image != "alpine:3" { + t.Errorf("expected image 'alpine:3', got %q", cfg.Image) + } +} + func strPtr(s string) *string { return &s } diff --git a/pkg/devcontainer/config/parse.go b/pkg/devcontainer/config/parse.go index d81aeb977..3a0217074 100644 --- a/pkg/devcontainer/config/parse.go +++ b/pkg/devcontainer/config/parse.go @@ -101,6 +101,13 @@ func ParseDevContainerJSONFile(jsonFilePath string) (*DevContainerConfig, error) if !devContainer.Extends.IsEmpty() { visited := map[string]bool{path: true} declaringDir := filepath.Dir(path) + + // Substitute local-scope variables in extends paths before resolution. + replacer := extendsVarReplacer(declaringDir) + for i, ref := range devContainer.Extends { + devContainer.Extends[i] = ResolveString(ref, replacer) + } + parent, err := resolveExtendsArray( context.TODO(), devContainer.Extends, @@ -284,6 +291,33 @@ func Convert(from any, to any) error { return json.Unmarshal(out, to) } +// extendsVarReplacer returns a ReplaceFunction that resolves only local-scope +// variables suitable for use before the container exists. +func extendsVarReplacer(localWorkspaceFolder string) ReplaceFunction { + return func(match, variable string, args []string) string { + switch variable { + case "localEnv": + if len(args) > 0 { + val := os.Getenv(args[0]) + if val != "" { + return val + } + if len(args) > 1 { + return strings.Join(args[1:], ":") + } + return "" + } + return match + case "localWorkspaceFolder": + return localWorkspaceFolder + case "localWorkspaceFolderBasename": + return filepath.Base(localWorkspaceFolder) + default: + return match + } + } +} + func ParseKeyValueFile(filename string) ([]string, error) { f, err := os.Open(filename) if err != nil { From 4575c41dbd635d027e1ede47ec8367815f3b2182 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 7 May 2026 00:18:31 -0500 Subject: [PATCH 2/4] fix(config): use LookupEnv for spec-correct default handling in extends Use os.LookupEnv instead of os.Getenv so that an env var set to empty string is returned as-is, and the default value is only used when the variable does not exist. Adds test for default value syntax. Also resolves pre-existing merge conflict in cmd/root.go. --- pkg/devcontainer/config/extends_test.go | 35 +++++++++++++++++++++++++ pkg/devcontainer/config/parse.go | 5 ++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/pkg/devcontainer/config/extends_test.go b/pkg/devcontainer/config/extends_test.go index 8bda82080..f3f8af3be 100644 --- a/pkg/devcontainer/config/extends_test.go +++ b/pkg/devcontainer/config/extends_test.go @@ -758,6 +758,41 @@ func TestExtends_MissingEnvResolvesToEmpty(t *testing.T) { } } +func TestExtends_LocalEnvDefaultValue(t *testing.T) { + tmpDir := t.TempDir() + + fallbackDir := filepath.Join(tmpDir, "fallback") + // #nosec G301 -- test directory + if err := os.MkdirAll(fallbackDir, 0o750); err != nil { + t.Fatal(err) + } + writeJSON(t, fallbackDir, "parent.json", `{ + "name": "parent", + "image": "debian:12" + }`) + + t.Setenv("UNSET_VAR_FOR_EXTENDS_TEST", "") + if err := os.Unsetenv("UNSET_VAR_FOR_EXTENDS_TEST"); err != nil { + t.Fatal(err) + } + + writeJSON(t, tmpDir, "child.json", `{ + "extends": "${localEnv:UNSET_VAR_FOR_EXTENDS_TEST:fallback}/parent.json", + "name": "child" + }`) + + cfg, err := ParseDevContainerJSONFile(filepath.Join(tmpDir, "child.json")) + if err != nil { + t.Fatal(err) + } + if cfg.Name != testNameChild { + t.Errorf("expected name 'child', got %q", cfg.Name) + } + if cfg.Image != "debian:12" { + t.Errorf("expected image 'debian:12', got %q", cfg.Image) + } +} + func TestExtends_LocalWorkspaceFolderBasenameInPath(t *testing.T) { tmpDir := t.TempDir() basename := filepath.Base(tmpDir) diff --git a/pkg/devcontainer/config/parse.go b/pkg/devcontainer/config/parse.go index 3a0217074..1da68fb41 100644 --- a/pkg/devcontainer/config/parse.go +++ b/pkg/devcontainer/config/parse.go @@ -102,7 +102,6 @@ func ParseDevContainerJSONFile(jsonFilePath string) (*DevContainerConfig, error) visited := map[string]bool{path: true} declaringDir := filepath.Dir(path) - // Substitute local-scope variables in extends paths before resolution. replacer := extendsVarReplacer(declaringDir) for i, ref := range devContainer.Extends { devContainer.Extends[i] = ResolveString(ref, replacer) @@ -298,8 +297,8 @@ func extendsVarReplacer(localWorkspaceFolder string) ReplaceFunction { switch variable { case "localEnv": if len(args) > 0 { - val := os.Getenv(args[0]) - if val != "" { + val, ok := os.LookupEnv(args[0]) + if ok { return val } if len(args) > 1 { From e94c1ed8f901f3e713f261aee25f5d5459450c45 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 11 May 2026 15:15:11 -0500 Subject: [PATCH 3/4] test(e2e): add extends variable substitution tests Cover localEnv, localWorkspaceFolder, default-value fallback, and missing-env-var error scenarios for extends path resolution. --- e2e/tests/extends/extends.go | 123 ++++++++++++++++++ .../.devcontainer/devcontainer.json | 4 + .../.devcontainer/fallback/devcontainer.json | 7 + .../.devcontainer/devcontainer.json | 4 + .../.devcontainer/devcontainer.json | 4 + .../varsub-localenv/parent/devcontainer.json | 7 + .../configs/parent/devcontainer.json | 7 + .../.devcontainer/devcontainer.json | 4 + 8 files changed, 160 insertions(+) create mode 100644 e2e/tests/extends/testdata/varsub-localenv-default/.devcontainer/devcontainer.json create mode 100644 e2e/tests/extends/testdata/varsub-localenv-default/.devcontainer/fallback/devcontainer.json create mode 100644 e2e/tests/extends/testdata/varsub-localenv-missing/.devcontainer/devcontainer.json create mode 100644 e2e/tests/extends/testdata/varsub-localenv/.devcontainer/devcontainer.json create mode 100644 e2e/tests/extends/testdata/varsub-localenv/parent/devcontainer.json create mode 100644 e2e/tests/extends/testdata/varsub-workspace-folder/.devcontainer/configs/parent/devcontainer.json create mode 100644 e2e/tests/extends/testdata/varsub-workspace-folder/.devcontainer/devcontainer.json diff --git a/e2e/tests/extends/extends.go b/e2e/tests/extends/extends.go index 2e37e973b..6706f2d97 100644 --- a/e2e/tests/extends/extends.go +++ b/e2e/tests/extends/extends.go @@ -300,6 +300,129 @@ var _ = ginkgo.Describe("extends property", ginkgo.Label("extends"), func() { gomega.HaveKey("ghcr.io/devcontainers/features/go:1"), ) }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It( + "resolves ${localEnv:VAR} variable substitution in extends path", + ginkgo.Label("extends"), + func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + tempDir, err := framework.CopyToTempDirWithoutChdir( + "tests/extends/testdata/varsub-localenv", + ) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(tempDir) }) + + gomega.Expect(os.Setenv("TEST_EXTENDS_DIR", tempDir)).To(gomega.Succeed()) + ginkgo.DeferCleanup(func() { + gomega.Expect(os.Unsetenv("TEST_EXTENDS_DIR")).To(gomega.Succeed()) + }) + + stdout, _, err := readConfiguration(ctx, f, tempDir) + framework.ExpectNoError(err) + + config := parseConfigFromOutput(stdout) + gomega.Expect(config).To( + gomega.HaveKeyWithValue("name", "LocalEnv Extends Child"), + ) + gomega.Expect(config).To( + gomega.HaveKeyWithValue( + "image", + "ghcr.io/devsy-org/test-images/base:ubuntu", + ), + ) + gomega.Expect(config).To(gomega.HaveKeyWithValue("remoteUser", "vscode")) + + containerEnv, ok := config["containerEnv"].(map[string]any) + gomega.Expect(ok).To(gomega.BeTrue(), "containerEnv should be an object") + gomega.Expect(containerEnv).To( + gomega.HaveKeyWithValue("FROM_PARENT", "parent-value"), + ) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It( + "resolves ${localWorkspaceFolder} variable substitution in extends path", + ginkgo.Label("extends"), + func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + tempDir, err := framework.CopyToTempDirWithoutChdir( + "tests/extends/testdata/varsub-workspace-folder", + ) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(tempDir) }) + + stdout, _, err := readConfiguration(ctx, f, tempDir) + framework.ExpectNoError(err) + + config := parseConfigFromOutput(stdout) + gomega.Expect(config).To( + gomega.HaveKeyWithValue("name", "Workspace Folder Extends Child"), + ) + gomega.Expect(config).To( + gomega.HaveKeyWithValue( + "image", + "ghcr.io/devsy-org/test-images/base:ubuntu", + ), + ) + gomega.Expect(config).To(gomega.HaveKeyWithValue("remoteUser", "vscode")) + + containerEnv, ok := config["containerEnv"].(map[string]any) + gomega.Expect(ok).To(gomega.BeTrue(), "containerEnv should be an object") + gomega.Expect(containerEnv).To( + gomega.HaveKeyWithValue("FROM_PARENT", "workspace-parent-value"), + ) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It( + "resolves ${localEnv:VAR:default} with fallback when env var is unset", + ginkgo.Label("extends"), + func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + tempDir, err := framework.CopyToTempDirWithoutChdir( + "tests/extends/testdata/varsub-localenv-default", + ) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(tempDir) }) + + gomega.Expect(os.Unsetenv("DEVSY_UNSET_VAR")).To(gomega.Succeed()) + + stdout, _, err := readConfiguration(ctx, f, tempDir) + framework.ExpectNoError(err) + + config := parseConfigFromOutput(stdout) + gomega.Expect(config).To( + gomega.HaveKeyWithValue("name", "LocalEnv Default Extends Child"), + ) + gomega.Expect(config).To( + gomega.HaveKeyWithValue( + "image", + "ghcr.io/devsy-org/test-images/base:ubuntu", + ), + ) + gomega.Expect(config).To(gomega.HaveKeyWithValue("remoteUser", "vscode")) + + containerEnv, ok := config["containerEnv"].(map[string]any) + gomega.Expect(ok).To(gomega.BeTrue(), "containerEnv should be an object") + gomega.Expect(containerEnv).To( + gomega.HaveKeyWithValue("FROM_FALLBACK", "fallback-value"), + ) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It( + "returns error when ${localEnv:VAR} resolves to empty for missing env var", + ginkgo.Label("extends"), + func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + tempDir, err := framework.CopyToTempDirWithoutChdir( + "tests/extends/testdata/varsub-localenv-missing", + ) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(tempDir) }) + + gomega.Expect(os.Unsetenv("DEVSY_NONEXISTENT_VAR")).To(gomega.Succeed()) + + _, _, err = readConfiguration(ctx, f, tempDir) + framework.ExpectError(err) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) }) func pushOCIImage(refStr, jsonContent string) { diff --git a/e2e/tests/extends/testdata/varsub-localenv-default/.devcontainer/devcontainer.json b/e2e/tests/extends/testdata/varsub-localenv-default/.devcontainer/devcontainer.json new file mode 100644 index 000000000..f4eb55897 --- /dev/null +++ b/e2e/tests/extends/testdata/varsub-localenv-default/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "extends": "${localEnv:DEVSY_UNSET_VAR:fallback}/devcontainer.json", + "name": "LocalEnv Default Extends Child" +} diff --git a/e2e/tests/extends/testdata/varsub-localenv-default/.devcontainer/fallback/devcontainer.json b/e2e/tests/extends/testdata/varsub-localenv-default/.devcontainer/fallback/devcontainer.json new file mode 100644 index 000000000..7c1f881c7 --- /dev/null +++ b/e2e/tests/extends/testdata/varsub-localenv-default/.devcontainer/fallback/devcontainer.json @@ -0,0 +1,7 @@ +{ + "image": "ghcr.io/devsy-org/test-images/base:ubuntu", + "remoteUser": "vscode", + "containerEnv": { + "FROM_FALLBACK": "fallback-value" + } +} diff --git a/e2e/tests/extends/testdata/varsub-localenv-missing/.devcontainer/devcontainer.json b/e2e/tests/extends/testdata/varsub-localenv-missing/.devcontainer/devcontainer.json new file mode 100644 index 000000000..7ebe2d02f --- /dev/null +++ b/e2e/tests/extends/testdata/varsub-localenv-missing/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "extends": "${localEnv:DEVSY_NONEXISTENT_VAR}/devcontainer.json", + "name": "Missing Env Var Child" +} diff --git a/e2e/tests/extends/testdata/varsub-localenv/.devcontainer/devcontainer.json b/e2e/tests/extends/testdata/varsub-localenv/.devcontainer/devcontainer.json new file mode 100644 index 000000000..6988ba345 --- /dev/null +++ b/e2e/tests/extends/testdata/varsub-localenv/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "extends": "${localEnv:TEST_EXTENDS_DIR}/parent/devcontainer.json", + "name": "LocalEnv Extends Child" +} diff --git a/e2e/tests/extends/testdata/varsub-localenv/parent/devcontainer.json b/e2e/tests/extends/testdata/varsub-localenv/parent/devcontainer.json new file mode 100644 index 000000000..85f4a0dfc --- /dev/null +++ b/e2e/tests/extends/testdata/varsub-localenv/parent/devcontainer.json @@ -0,0 +1,7 @@ +{ + "image": "ghcr.io/devsy-org/test-images/base:ubuntu", + "remoteUser": "vscode", + "containerEnv": { + "FROM_PARENT": "parent-value" + } +} diff --git a/e2e/tests/extends/testdata/varsub-workspace-folder/.devcontainer/configs/parent/devcontainer.json b/e2e/tests/extends/testdata/varsub-workspace-folder/.devcontainer/configs/parent/devcontainer.json new file mode 100644 index 000000000..8035b52b9 --- /dev/null +++ b/e2e/tests/extends/testdata/varsub-workspace-folder/.devcontainer/configs/parent/devcontainer.json @@ -0,0 +1,7 @@ +{ + "image": "ghcr.io/devsy-org/test-images/base:ubuntu", + "remoteUser": "vscode", + "containerEnv": { + "FROM_PARENT": "workspace-parent-value" + } +} diff --git a/e2e/tests/extends/testdata/varsub-workspace-folder/.devcontainer/devcontainer.json b/e2e/tests/extends/testdata/varsub-workspace-folder/.devcontainer/devcontainer.json new file mode 100644 index 000000000..69ffd3669 --- /dev/null +++ b/e2e/tests/extends/testdata/varsub-workspace-folder/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "extends": "${localWorkspaceFolder}/configs/parent/devcontainer.json", + "name": "Workspace Folder Extends Child" +} From 5c9f55b9a1d2348ff05fc9df810dc2a539275ba8 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 11 May 2026 15:30:11 -0500 Subject: [PATCH 4/4] fix(lint): extract goconst constants for localEnv and localWorkspaceFolder Adding extendsVarReplacer introduced two more string literal occurrences of "localEnv" and "localWorkspaceFolder", crossing the goconst threshold. Extracted varLocalEnv and varLocalWorkspaceFolder constants shared across substitute.go and parse.go to satisfy the linter. --- pkg/devcontainer/config/parse.go | 4 ++-- pkg/devcontainer/config/substitute.go | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/pkg/devcontainer/config/parse.go b/pkg/devcontainer/config/parse.go index 1da68fb41..aa7816007 100644 --- a/pkg/devcontainer/config/parse.go +++ b/pkg/devcontainer/config/parse.go @@ -295,7 +295,7 @@ func Convert(from any, to any) error { func extendsVarReplacer(localWorkspaceFolder string) ReplaceFunction { return func(match, variable string, args []string) string { switch variable { - case "localEnv": + case varLocalEnv: if len(args) > 0 { val, ok := os.LookupEnv(args[0]) if ok { @@ -307,7 +307,7 @@ func extendsVarReplacer(localWorkspaceFolder string) ReplaceFunction { return "" } return match - case "localWorkspaceFolder": + case varLocalWorkspaceFolder: return localWorkspaceFolder case "localWorkspaceFolderBasename": return filepath.Base(localWorkspaceFolder) diff --git a/pkg/devcontainer/config/substitute.go b/pkg/devcontainer/config/substitute.go index 6d8e8c2a6..cd84ae883 100644 --- a/pkg/devcontainer/config/substitute.go +++ b/pkg/devcontainer/config/substitute.go @@ -20,6 +20,9 @@ const ( LabelLocalFolder = "devcontainer.local_folder" LabelConfigFile = "devcontainer.config_file" + + varLocalEnv = "localEnv" + varLocalWorkspaceFolder = "localWorkspaceFolder" ) type ReplaceFunction func(match, variable string, args []string) string @@ -147,9 +150,9 @@ func replaceWithContext( return substitutionCtx.DevContainerID } return match - case "localEnv": + case varLocalEnv: return lookupValue(isWindows, substitutionCtx.Env, args, match) - case "localWorkspaceFolder": + case varLocalWorkspaceFolder: if substitutionCtx.LocalWorkspaceFolder != "" { return substitutionCtx.LocalWorkspaceFolder }