You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
#57 (original array-option request, open since 2022), #44 (install a feature more than once), devcontainers/cli#1298 (CLI implementation)
Supersedes the workaround
Comma-separated strings, documented in #57 as a "near term workaround" that was never replaced.
Summary
Feature option values today may only be boolean or string. Every Feature that needs a list of
values (packages, extensions, tools, versions, ports) is forced to accept a comma-separated
string and split it inside install.sh. This RFC makes arrays a first-class option value type
in the Development Containers specification, and defines how arrays are serialized to the devcontainer-features.env environment variables that install.sh consumes.
It covers two complementary forms of "array input", both of which are required:
Both are required, not optional. The status quo (comma-separated strings and single-invocation
features) is a documented workaround that has now persisted for 3+ years and is actively causing
the problems enumerated below.
Motivation
The current type system is the bottleneck
The spec explicitly restricts option types (source):
optionId.type — Type of the option. Valid types are currently: boolean, string
There is no array. As a direct consequence, the reference CLI encodes the same limitation in its
types — FeatureOption is a union of only 'boolean' and 'string'
(containerFeaturesConfiguration.ts),
and feature option values are typed as string | boolean
(configuration.ts).
This single missing type forces every list-taking Feature to invent its own string-encoding scheme.
Real-world Features are already broken by comma-separated strings
This is not theoretical. Shipped, widely-used Features encode lists as comma-separated strings today:
Documented as "Comma separated list of packages". A package spec containing a comma (e.g. some pak remotes) is unrepresentable.
ghcr.io/devcontainers/features/github-cli
extensions
"github/gh-copilot"
"comma-separated list of extensions". Extension refs/args containing commas break.
mwmahlberg/devcontainer-featuresnpm-packages
packages
"typescript,eslint"
Accepts comma-, whitespace-, OR newline-separated — three delimiters, because no canonical form exists. Each author picks differently.
The npm-packages case is the clearest signal of failure: because the spec provides no array type, every Feature author independently chooses a different delimiter. Consumers cannot reason about
a list option without reading each Feature's install.sh.
Arrays already exist everywhere else in devcontainer.json
The spec already treats arrays as first-class for non-option properties:
Lifecycle hooks: onCreateCommand/postCreateCommand/… are [string, array, object]
For the devcontainer.json the JSON array makes sense (to stay in JSON)… A devcontainer-feature.json
could use a subset of JSON schema to define a feature's options… This will also give us a clear
path to supporting nested objects and arrays.
The "near term workaround" has been the de facto standard for three years. This RFC closes that gap.
Goals
Add array as a normative option type in devcontainer-feature.json.
Permit JSON arrays as option values in devcontainer.json for array-typed options.
Define Option Resolution for arrays — how an array value is serialized into the devcontainer-features.env environment variable that install.sh sources.
Define CLI semantics for passing arrays, so all implementing tools agree on behavior.
Non-goals
Nested objects/arrays inside option values (a future JSON-Schema-subset proposal can build on this).
Changing the existing boolean/string option behavior.
Detailed proposal
Part A — Array-valued options
A.1 New option type array in devcontainer-feature.json
Extend the options property table. Add a row:
Property
Type
Description
optionId.type
string
Type of the option. Valid types: boolean, string, array
And define the array option shape:
{
"options": {
"packages": {
"type": "array",
"proposals": ["curl", "git", "jq", "wget"], // suggested values (free-form still allowed)"enum": ["curl", "git", "jq"], // strict list (mutually exclusive with proposals)"default": ["curl", "git"], // array of primitives"description": "OS packages to install."
}
}
}
optionId.default for an array option is an array of primitives (string/boolean/number).
optionId.proposals / optionId.enum (when present) constrain the elements, not the whole
value. enum means every element MUST be in the list; proposals suggests elements but allows
others.
An array option with no default defaults to [] (empty array).
The value of an array-typed option MUST be a JSON array of primitives. Implementations MUST reject
non-array values for an array-typed option unless the value is a string, in which case the
backwards-compatibility rule in §A.4 applies.
A.3 Option Resolution for arrays (the serialization contract)
Today, options are emitted to devcontainer-features.env as <OPTION_NAME>=<value> and sourced by install.sh. For an array option, the implementation MUST emit the value as a JSON array
string:
PACKAGES='["curl","git","jq"]'
Rationale — JSON is the only delimiter-free encoding that is unambiguous for values containing
commas, spaces, newlines, or quotes. Space/newline/comma separation all break on at least one of
those characters (the exact failure mode the current workaround exhibits).
install.sh consumes it trivially with jq, which is already ubiquitous in dev container base
images and is itself a devcontainers-published Feature:
Note: The spec does not mandate jq. It mandates the JSON string contract. Features may
parse it however they wish; jq is shown only because it is the idiomatic choice.
For an array-typed option, if the user supplies a string instead of an array:
If the string parses as a JSON array (["a","b"]), implementations MUST treat it as that array.
Otherwise, implementations MUST split the string on commas and trim each element, yielding the
array. This preserves the existing comma-separated behavior for every Feature that migrates to type: "array".
Implementations SHOULD emit a warning when the comma-split path is taken, recommending the array
form.
This means migration is non-breaking: an existing Feature that switches its option from type: "string" to type: "array" continues to accept "cli,rlang" while also gaining ["cli","rlang"].
Part B — Array of option objects (multiple invocations)
Resolves #44. A Feature value may be an array of option objects, each producing a separate install.sh invocation in order:
Each element is a normal options object (same shape as today's single-object value).
The Feature is invoked once per element, in array order, after applying installsAfter/dependsOn
ordering relative to other Features.
Option Resolution runs per invocation: each install.sh sees only that element's env vars.
The shorthand string value ("go": "1.18") remains valid and is equivalent to a single-element
array [{ "version": "1.18" }].
This is required because array-valued options (Part A) alone cannot express "install this Feature
twice with two independent sets of options" — a need called out by maintainers in #44 and not
addressable by Part A.
CLI semantics (normative recommendation)
Implementing tools that accept Feature options on the command line MUST support arrays via:
JSON — the option value is a JSON array string:
devcontainer up --override-features '{"./my-feature": {"packages": ["curl","git"]}}'
Repeated flags (recommended where the CLI surface permits) — appending to the same option:
devcontainer up --feature-option my-feature.packages curl \
--feature-option my-feature.packages git
For Part B (multiple invocations), the CLI MUST accept a JSON array of option objects as the
Feature value in --override-features.
Use cases
Package lists without delimiter ambiguity.r-packages, npm-packages, github-cli
extensions, Homebrew formulae — all currently comma-joined. Arrays make the list explicit and
allow values that themselves contain commas (e.g. package specs with version constraints or
git refs with query strings).
Multiple runtime versions in one image. Install .NET 3.1 and .NET 6.0, or Node 18 and 20, in a single dev container via Part B — without authoring a bespoke "multi-version" option
per Feature.
Multi-select tool bundles. A Feature offering a curated toolset where the user picks any
subset (e.g. ["vim", "jq", "htop"]). With enum constrained elements, the UX tool can render a
multi-select picker — exactly the pattern Coder uses for list(string) parameters.
Lists of endpoints/ports/mounts contributed by a Feature. Any Feature that today builds a
delimited string to pass several homogeneous values becomes a clean array.
Deterministic, tool-readable configuration. Schema validation, IntelliSense, and diffing all
work natively on JSON arrays; comma-joined strings are opaque to every tool except the one install.sh that splits them.
Prior art (alternative stacks)
The Development Containers spec is the only major dev-environment definition format that lacks a
native list type for user-supplied option values:
First-class list type. Notably, Coder's docs warn that overriding list(string) on the CLI is "tricky" due to CSV+JSON quoting, and offer a YAML file workaround — a cautionary example of why the spec must define array semantics up front rather than leaving it to each CLI.
Nix (flake.nix / mkShell)
Native Nix lists: packages = [ curl git jq ];
First-class; no string parsing anywhere.
Gitpod (.gitpod.yml)
Native YAML arrays for tasks, ports, vscode.extensions
First-class sequences; no delimiter encoding.
Docker Compose
Native YAML arrays for volumes, ports, environment, env_file
First-class.
Helm
Native YAML arrays in values.yaml, iterated with range
First-class.
Terraform (underlying Coder)
list(string), list(any) as native variable types
First-class.
Dev Containers (this spec)
❌ No array option type — comma-separated string only
Outlier.
Every comparable stack ships native list types. The devcontainer ecosystem's comma-string
convention is the exception, not the rule, and it is the exception because the workaround was never
replaced.
Normative requirements
The spec MUST add array to the set of valid optionId.type values.
The spec MUST define that an array option's value is a JSON array of primitives in devcontainer.json.
The spec MUST define Option Resolution for arrays: the env var holds the JSON array string.
The spec MUST define string→array coercion (JSON-parse, else comma-split) for backward
compatibility.
The spec MUST permit a Feature value to be an array of option objects (Part B), invoking the
Feature once per element in order.
The spec MUST define CLI semantics for arrays (JSON value; repeated flags recommended).
The JSON Schema for devcontainer-feature.json and devcontainer.json MUST be updated to allow
these shapes.
Acceptance criteria
optionId.type documents boolean, string, array with the array option shape.
Option Resolution section defines JSON-string serialization for array values, with an example.
String→array coercion rule documented with a deprecation warning recommendation.
Array-of-option-objects Feature value documented (Part B), with ordering semantics.
CLI recommendation section covers JSON and repeated-flag forms.
devContainerFeature.schema.json and the devcontainer.json schema updated.
At least one end-to-end example showing a migrated Feature (e.g. r-packages).
Open questions
Should the spec also emit a newline-separated companion env var (e.g. PACKAGES_LIST) for
shell convenience, in addition to the canonical JSON string? (Recommendation: no — keep one
canonical form; jq is sufficient and avoids two sources of truth.)
For Part B, should installsAfter/dependsOn ordering interleave the multiple invocations of a
single Feature, or treat the whole array as one unit relative to other Features? (Recommendation:
one unit — all invocations of Feature X run consecutively, in array order, at X's position in the
install order.)
RFC: First-class array values for Feature options
finalizationtrackoptionsproperty and Option ResolutionSummary
Feature option values today may only be
booleanorstring. Every Feature that needs a list ofvalues (packages, extensions, tools, versions, ports) is forced to accept a comma-separated
string and split it inside
install.sh. This RFC makes arrays a first-class option value typein the Development Containers specification, and defines how arrays are serialized to the
devcontainer-features.envenvironment variables thatinstall.shconsumes.It covers two complementary forms of "array input", both of which are required:
(
"packages": ["curl", "git", "jq"]). Resolves Providing a set of values to a single featureoption#57.options (
"dotnet": [{"version":"3.1"}, {"version":"6.0"}]). Resolves Installing a dev containerfeaturemore than once in adevcontainer.json#44.Both are required, not optional. The status quo (comma-separated strings and single-invocation
features) is a documented workaround that has now persisted for 3+ years and is actively causing
the problems enumerated below.
Motivation
The current type system is the bottleneck
The spec explicitly restricts option types (source):
There is no
array. As a direct consequence, the reference CLI encodes the same limitation in itstypes —
FeatureOptionis a union of only'boolean'and'string'(
containerFeaturesConfiguration.ts),and feature option values are typed as
string | boolean(
configuration.ts).This single missing type forces every list-taking Feature to invent its own string-encoding scheme.
Real-world Features are already broken by comma-separated strings
This is not theoretical. Shipped, widely-used Features encode lists as comma-separated strings today:
ghcr.io/rocker-org/devcontainer-features/r-packages:1packages"cli,rlang"pakremotes) is unrepresentable.ghcr.io/devcontainers/features/github-cli"github/gh-copilot"mwmahlberg/devcontainer-featuresnpm-packagespackages"typescript,eslint"The
npm-packagescase is the clearest signal of failure: because the spec provides no array type,every Feature author independently chooses a different delimiter. Consumers cannot reason about
a list option without reading each Feature's
install.sh.Arrays already exist everywhere else in
devcontainer.jsonThe spec already treats arrays as first-class for non-option properties:
onCreateCommand/postCreateCommand/… are[string, array, object]forwardPorts: (number | string)[]mounts: (Mount | string)[]runArgs: string[],capAdd: string[],securityOpt: string[]installsAfter: string[],legacyIds: string[],keywords: arrayOnly Feature option values are denied arrays. This is an inconsistency, not a deliberate design
constraint.
The workaround was always intended to be temporary
From #57, maintainer Chuck Lantz (@Chuxel) (2022):
And Christof Marti (@chrmarti) (2022):
The "near term workaround" has been the de facto standard for three years. This RFC closes that gap.
Goals
arrayas a normative optiontypeindevcontainer-feature.json.devcontainer.jsonfor array-typed options.once with different options (resolving Installing a dev container
featuremore than once in adevcontainer.json#44).devcontainer-features.envenvironment variable thatinstall.shsources.Non-goals
boolean/stringoption behavior.Detailed proposal
Part A — Array-valued options
A.1 New option type
arrayindevcontainer-feature.jsonExtend the
optionsproperty table. Add a row:optionId.typeboolean,string,arrayAnd define the
arrayoption shape:{ "options": { "packages": { "type": "array", "proposals": ["curl", "git", "jq", "wget"], // suggested values (free-form still allowed) "enum": ["curl", "git", "jq"], // strict list (mutually exclusive with proposals) "default": ["curl", "git"], // array of primitives "description": "OS packages to install." } } }optionId.defaultfor anarrayoption is an array of primitives (string/boolean/number).optionId.proposals/optionId.enum(when present) constrain the elements, not the wholevalue.
enummeans every element MUST be in the list;proposalssuggests elements but allowsothers.
arrayoption with nodefaultdefaults to[](empty array).A.2 Array values in
devcontainer.jsonThe value of an array-typed option MUST be a JSON array of primitives. Implementations MUST reject
non-array values for an
array-typed option unless the value is a string, in which case thebackwards-compatibility rule in §A.4 applies.
A.3 Option Resolution for arrays (the serialization contract)
Today, options are emitted to
devcontainer-features.envas<OPTION_NAME>=<value>and sourced byinstall.sh. For anarrayoption, the implementation MUST emit the value as a JSON arraystring:
Rationale — JSON is the only delimiter-free encoding that is unambiguous for values containing
commas, spaces, newlines, or quotes. Space/newline/comma separation all break on at least one of
those characters (the exact failure mode the current workaround exhibits).
install.shconsumes it trivially withjq, which is already ubiquitous in dev container baseimages and is itself a devcontainers-published Feature:
A.4 Backward compatibility (string → array coercion)
For an
array-typed option, if the user supplies a string instead of an array:["a","b"]), implementations MUST treat it as that array.array. This preserves the existing comma-separated behavior for every Feature that migrates to
type: "array".form.
This means migration is non-breaking: an existing Feature that switches its option from
type: "string"totype: "array"continues to accept"cli,rlang"while also gaining["cli","rlang"].Part B — Array of option objects (multiple invocations)
Resolves #44. A Feature value may be an array of option objects, each producing a separate
install.shinvocation in order:installsAfter/dependsOnordering relative to other Features.
install.shsees only that element's env vars."go": "1.18") remains valid and is equivalent to a single-elementarray
[{ "version": "1.18" }].This is required because array-valued options (Part A) alone cannot express "install this Feature
twice with two independent sets of options" — a need called out by maintainers in #44 and not
addressable by Part A.
CLI semantics (normative recommendation)
Implementing tools that accept Feature options on the command line MUST support arrays via:
devcontainer up --override-features '{"./my-feature": {"packages": ["curl","git"]}}'devcontainer up --feature-option my-feature.packages curl \ --feature-option my-feature.packages gitFor Part B (multiple invocations), the CLI MUST accept a JSON array of option objects as the
Feature value in
--override-features.Use cases
Package lists without delimiter ambiguity.
r-packages,npm-packages,github-cliextensions, Homebrew formulae — all currently comma-joined. Arrays make the list explicit and
allow values that themselves contain commas (e.g. package specs with version constraints or
git refs with query strings).
Multiple runtime versions in one image. Install
.NET 3.1and.NET 6.0, or Node18and20, in a single dev container via Part B — without authoring a bespoke "multi-version" optionper Feature.
Multi-select tool bundles. A Feature offering a curated toolset where the user picks any
subset (e.g.
["vim", "jq", "htop"]). Withenumconstrained elements, the UX tool can render amulti-select picker — exactly the pattern Coder uses for
list(string)parameters.Lists of endpoints/ports/mounts contributed by a Feature. Any Feature that today builds a
delimited string to pass several homogeneous values becomes a clean array.
Deterministic, tool-readable configuration. Schema validation, IntelliSense, and diffing all
work natively on JSON arrays; comma-joined strings are opaque to every tool except the one
install.shthat splits them.Prior art (alternative stacks)
The Development Containers spec is the only major dev-environment definition format that lacks a
native list type for user-supplied option values:
coder_parametertype = "list(string)"; UI rendersmulti-select/tag-selectforms; defaults viajsonencode([...])list(string)on the CLI is "tricky" due to CSV+JSON quoting, and offer a YAML file workaround — a cautionary example of why the spec must define array semantics up front rather than leaving it to each CLI.flake.nix/mkShell)packages = [ curl git jq ];.gitpod.yml)tasks,ports,vscode.extensionsvolumes,ports,environment,env_filevalues.yaml, iterated withrangelist(string),list(any)as native variable typesstringonlyEvery comparable stack ships native list types. The devcontainer ecosystem's comma-string
convention is the exception, not the rule, and it is the exception because the workaround was never
replaced.
Normative requirements
arrayto the set of validoptionId.typevalues.arrayoption's value is a JSON array of primitives indevcontainer.json.compatibility.
Feature once per element in order.
devcontainer-feature.jsonanddevcontainer.jsonMUST be updated to allowthese shapes.
Acceptance criteria
optionId.typedocumentsboolean,string,arraywith the array option shape.devContainerFeature.schema.jsonand thedevcontainer.jsonschema updated.r-packages).Open questions
PACKAGES_LIST) forshell convenience, in addition to the canonical JSON string? (Recommendation: no — keep one
canonical form;
jqis sufficient and avoids two sources of truth.)installsAfter/dependsOnordering interleave the multiple invocations of asingle Feature, or treat the whole array as one unit relative to other Features? (Recommendation:
one unit — all invocations of Feature X run consecutively, in array order, at X's position in the
install order.)
References
option#57 (open since June 2022)featuremore than once in adevcontainer.json#44list(string)parameters: https://coder.com/docs/admin/templates/extending-templates/parameters