Symptom
Container create fails when a feature contributes a mount whose source uses a substitution variable. Observed with the docker-in-docker feature, which declares:
"mounts": [{
"source": "dind-var-lib-docker-${devcontainerId}",
"target": "/var/lib/docker",
"type": "volume"
}]
Docker daemon rejects the create:
create dind-var-lib-docker-${devcontainerId}: "dind-var-lib-docker-${devcontainerId}" includes invalid characters for a local volume name, only "[a-zA-Z0-9][a-zA-Z0-9_.-]" are allowed.
The literal ${devcontainerId} reaches ContainerCreate because it was never substituted.
Root cause
config/resolve.go:190 — substituteAll runs as part of ResolveBytes. This correctly resolves ${devcontainerId} in the user's devcontainer.json mounts.
up.go:264, up.go:278, up.go:485 — later, applyMetadataMerge → config.MergeMetadata appends feature-contributed mounts to cfg.Mounts (config/merge_metadata.go:76-81).
- No substitution runs after the merge. Feature mount sources keep their literal
${devcontainerId} and flow straight into the ContainerCreate payload.
Same risk exists for any other string field a feature/base-metadata layer can contribute that may include substitution variables (containerEnv, remoteEnv, lifecycle hooks).
Suggested fix
Two shapes:
- Local fix: in
applyMetadataMerge (up.go:897), after MergeMetadata, run a substitution pass over the merged-in fields. Rebuild a SubstitutionContext from cfg.DevcontainerID, cfg.LocalWorkspaceFolder, cfg.ContainerWorkspaceFolder, and the same localEnv ResolveBytes saw.
- Cleaner: have
MergeMetadata accept a SubstitutionContext and substitute as it folds layers in, preserving the invariant that every string in ResolvedConfig is host-substituted.
Leaning toward the second so callers don't have to remember.
Repro
Any devcontainer that depends on the docker-in-docker feature (e.g. dap-workspace-55 in dap-ephraim-sandbox).
Symptom
Container create fails when a feature contributes a mount whose source uses a substitution variable. Observed with the docker-in-docker feature, which declares:
Docker daemon rejects the create:
The literal
${devcontainerId}reachesContainerCreatebecause it was never substituted.Root cause
config/resolve.go:190—substituteAllruns as part ofResolveBytes. This correctly resolves${devcontainerId}in the user'sdevcontainer.jsonmounts.up.go:264,up.go:278,up.go:485— later,applyMetadataMerge→config.MergeMetadataappends feature-contributed mounts tocfg.Mounts(config/merge_metadata.go:76-81).${devcontainerId}and flow straight into theContainerCreatepayload.Same risk exists for any other string field a feature/base-metadata layer can contribute that may include substitution variables (
containerEnv,remoteEnv, lifecycle hooks).Suggested fix
Two shapes:
applyMetadataMerge(up.go:897), afterMergeMetadata, run a substitution pass over the merged-in fields. Rebuild aSubstitutionContextfromcfg.DevcontainerID,cfg.LocalWorkspaceFolder,cfg.ContainerWorkspaceFolder, and the samelocalEnvResolveBytessaw.MergeMetadataaccept aSubstitutionContextand substitute as it folds layers in, preserving the invariant that every string inResolvedConfigis host-substituted.Leaning toward the second so callers don't have to remember.
Repro
Any devcontainer that depends on the docker-in-docker feature (e.g. dap-workspace-55 in dap-ephraim-sandbox).