From b4330066da7d477ae09840b1ec86b752a171ab45 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 4 May 2026 00:08:05 -0500 Subject: [PATCH] fix(config): remove unsupported ${env:VAR} variable substitution The devcontainer spec only supports ${localEnv:VAR} and ${containerEnv:VAR}. The generic ${env:VAR} was resolving identically to ${localEnv:VAR}, which violates the spec. Now ${env:VAR} falls through to the default case and is preserved as a literal. --- .../devcontainer-json.mdx | 2 +- pkg/devcontainer/config/substitute.go | 2 - pkg/devcontainer/config/substitute_test.go | 48 +++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/docs/pages/developing-in-workspaces/devcontainer-json.mdx b/docs/pages/developing-in-workspaces/devcontainer-json.mdx index 2289ebd00..b6a0fd5e7 100644 --- a/docs/pages/developing-in-workspaces/devcontainer-json.mdx +++ b/docs/pages/developing-in-workspaces/devcontainer-json.mdx @@ -92,7 +92,7 @@ field of the `devcontainer.json` file as follows: "customizations": { "devsy": { "featureDownloadHTTPHeaders": { - "FOO_HEADER": "${env:FOO_ENV_VAR}" + "FOO_HEADER": "${localEnv:FOO_ENV_VAR}" "BAR_HEADER": "bar" } } diff --git a/pkg/devcontainer/config/substitute.go b/pkg/devcontainer/config/substitute.go index f6838279b..01e5653d0 100644 --- a/pkg/devcontainer/config/substitute.go +++ b/pkg/devcontainer/config/substitute.go @@ -113,8 +113,6 @@ func replaceWithContext( return substitutionCtx.DevContainerID } return match - case "env": - fallthrough case "localEnv": return lookupValue(isWindows, substitutionCtx.Env, args, match) case "localWorkspaceFolder": diff --git a/pkg/devcontainer/config/substitute_test.go b/pkg/devcontainer/config/substitute_test.go index 5801516ab..288885dae 100644 --- a/pkg/devcontainer/config/substitute_test.go +++ b/pkg/devcontainer/config/substitute_test.go @@ -103,6 +103,54 @@ func TestReplaceWithContainerEnvUnknownPreservedAsLiteral(t *testing.T) { } } +func TestReplaceWithContextLocalEnvResolves(t *testing.T) { + ctx := &SubstitutionContext{ + Env: map[string]string{"HOME": "/root"}, + } + match := "${localEnv:HOME}" + result := replaceWithContext( + false, ctx, match, "localEnv", []string{"HOME"}, + ) + if result != "/root" { + t.Errorf( + "expected localEnv to resolve to %q, got %q", + "/root", result, + ) + } +} + +func TestReplaceWithContextEnvPreservedAsLiteral(t *testing.T) { + ctx := &SubstitutionContext{ + Env: map[string]string{"HOME": "/root"}, + } + match := "${env:HOME}" + result := replaceWithContext( + false, ctx, match, "env", []string{"HOME"}, + ) + if result != match { + t.Errorf( + "expected env var preserved as %q, got %q", + match, result, + ) + } +} + +func TestReplaceWithContextEnvDefaultPreservedAsLiteral(t *testing.T) { + ctx := &SubstitutionContext{ + Env: map[string]string{}, + } + match := "${env:MISSING:default}" + result := replaceWithContext( + false, ctx, match, "env", []string{"MISSING", "default"}, + ) + if result != match { + t.Errorf( + "expected env with default preserved as %q, got %q", + match, result, + ) + } +} + func TestResolveStringDefaultWithColons(t *testing.T) { replace := func(_, variable string, args []string) string { env := map[string]string{}