This CLI is validated for behavioral parity with the reference TypeScript
@devcontainers/cli (pinned at v0.90.0, see parity/). Where it
deliberately differs, the difference is recorded here — this is the durable record of
intentional departures from the oracle, not a backlog. User-facing additions are
documented in go-only-features.md.
These are intentional behavior differences; each is covered by tests and, where it touches a compared surface, reflected in the parity matrix.
- Go-only commands / flags (full reference in
go-only-features.md):checkandsetup(host preflight/remediation),up --cache-image(boot from a prebuilt image, skip build + feature install),read-configuration --cache-key(deterministic content hash; additive — default output is byte-identical to TS),build --secrets-file(BuildKit build secrets; TSbuildhas no such flag),--ca-bundleonbuildandup(extra CAs trusted in Feature installs, opted-in DockerfileRUNs and the container, mounted and never copied),open(launch VS Code attached to the workspace's dev container via a vscode-remote:// URI),stop/down(pause, or stop+remove, a workspace's container — Compose-aware; the upstream CLI leaves teardown to the editor), and the automatic credential bridge that hands the CLI's resolved auth todocker build. - A final
FROMline continued with a backslash keeps its stage name. TSensureDockerfileHasFinalStageNamereads only the physical line, so forFROM \followed byalpine AS baseit writesFROM \ AS dev, a Dockerfile no builder accepts. This CLI asks BuildKit for that stage's name and leaves the Dockerfile as it is (TestEnsureFinalStageNameReadsAContinuedFrom,internal/docker/testdata/ts-oracle/dockerfile.json). Every other FROM line is handled exactly as TS handles it. - Compose builds may read the Features Dockerfile outside the build context. Compose
builds through bake, and bake on buildx 0.38 and later refuses to read a file outside the
context unless the caller passes
--allow=fs.read, whichdocker compose buildcannot. The reference writes the Features Dockerfile under its cache folder and fails there with "additional privileges requested". This CLI starts itsdocker composeprocesses withBUILDX_BAKE_ENTITLEMENTS_FS=0unless the variable is already set (TestWithBakeFSEntitlement,TestE2E_ComposeBuildLabel). - A devcontainer.json that is not valid JSONC fails to load. TS parses the configuration
with jsonc-parser, whose error recovery keeps whatever it could read and reports nothing:
{badloads as{}, a missing comma or trailing garbage is skipped, and{"image": , "remoteUser": "u"}silently losesimage. This CLI stops with the parse error, naming the file, line and column (parse config <file>: hujson: line 1, column 2: ...), because running a configuration with properties silently dropped is worse than refusing it. Valid JSONC (comments, trailing commas) loads as in TS (TestLoadRejectsAConfigThatIsNotValidJSONC). --override-configdeep-merges the override onto the base config, whereas TS replaces the config wholesale (readDocument(overrideConfigFile ?? configFile)). With no readable base, the override stands alone — identical to TS. This lets an orchestrator pass a partial override. Only matrix case for the flag is the error path, so contract + semantic stay green.--terminal-log-filetees the same combined stream as--log-file. TS produces two files (a terminal stream with ANSI and a plain one); this CLI keeps a single log stream (no self-managed PTY — see decisions), so both flags capture the same output (without ANSI). Never a black hole.config.build.cacheFromis honored (wired to--cache-fromafter the flag's values) — matchingsingleContainer.ts. Upstream defines the field; this is a parity fix, noted here because it was previously a dead field.- OCI auth hardening is enforced through
oras-go, which is stricter by default.--oci-auth-hardeningand--allow-cross-origin-auth-hostbehave as documented upstream (bearer realms pinned to the registry authority or a trusted auth host, token endpoints may not redirect), andociAuthDiagnosticsreports the same three flags inup/build/read-configuration. The difference is what happens without the flag: the reference CLI still forwards registry credentials to a challenge that arrives from another origin, whileoras-gonever does (GHSA-vh4v-2xq2-g5cg). Hardening off is therefore already safe here; the diagnostics still report what hardening would change, so the flag remains a faithful compatibility probe. GITHUB_TOKENis sent only to GitHub's own hosts when a Feature is a tarball URL. TSgetRequestHeadersaddsAuthorization: Bearer $GITHUB_TOKENto any direct-tarball URI that starts withhttps://github.comorhttps://api.github.com, so it also hands the token tohttps://github.com.attacker.example/…,https://[email protected]/…(userinfo) andhttps://api.github.com.evil/…: anyone who can put a Feature URL in a devcontainer.json, or in a Feature'sdependsOn, can collect it. This CLI sends the token, and logsUsing environment GITHUB_TOKEN./No environment GITHUB_TOKEN available., only when the URL parses, its scheme ishttps, it has no userinfo and its host is exactlygithub.comorapi.github.com; for any other host it sends no token and logs neither line. Matching ignores case and one trailing dot, and allows any port, as TS does (TS normalizes the id withnew URL(...), which lowercases the host): these still name GitHub's host, and TLS verification binds the token to GitHub's certificate. No parity case can compare this with TS without leaking a token, so unit tests pin it:TestIsGitHubTarballURI,TestFeatureRequestHeadersSendTheTokenOnlyToGitHub(header and log lines for real and look-alike hosts) andTestDownloadFeatureTarballKeepsTheTokenFromALookAlikeURL(a TLS server reached throughhttps://github.com@<server>/…receives no token).BUILDKIT_INLINE_CACHE=1is omitted when--cache-tois an inline exporter (/type\s*=\s*inline/i), matching TSisBuildxCacheToInline— a parity fix over the earlier unconditional build-arg.- Two upstream fixes newer than the pinned reference are already ported. A
${localEnv:VAR:default}or${containerEnv:VAR:default}default keeps its colons (my.registry.io:5000/img; upstream #1213), where v0.90.0 keeps the text up to the next colon. A read-only mount keeps:roin the Compose override (upstream #881), where v0.90.0'sconvertMountToVolumedropsreadonly. Thetestdata/ts-oraclegoldens record both as divergence cases. - Dockerfile instructions are read with BuildKit's parser, not with the reference's
regular expressions, when resolving the base image and the
USERof a Dockerfile. BuildKit unquotes'...'values, reads everyNAME=valueof oneARG/ENVline and takesENV A = BasA== B, as Docker does; the reference keeps the single quotes, reads only the first pair and takesB. Variable expansion, stage lookup and the final stage name otherwise follow the reference (recorded ininternal/{docker,cli}/testdata/ts-oracle/dockerfile.json). - An unknown
${templateOption:NAME}is always replaced with the empty string. TSreplaceTemplatedValuesreadsoptions[NAME]from a plain object, so a name that is not an option but anObject.prototypeproperty (constructor,toString, …) becomes the inherited function's source text (function Object() { [native code] }) in the applied template. This CLI treats such a name like every other unknown option (internal/templates/testdata/ts-oracle/templates.json). - A
devcontainer-lock.jsonthat parses but is not shaped like a lockfile is an error. The reference keeps whateverJSON.parsereturns and looks each Feature up withlockfile?.features[id]?.…: an array, a number or a string throws aTypeErroronce a Feature is looked up, and afeaturesvalue that is not an object pins nothing. This CLI refuses such a file when it reads it (parse lockfile: …). JSON that does not parse, or a file that cannot be read, fails with the reference's own message, andnull,false,0and""are no lockfile, as in TS (internal/features/testdata/ts-oracle/lockfile.json). - The
devcontainer.metadatalabel writes object values with their keys sorted. The label is TS'sJSON.stringifyof the entries: each entry's properties in TS's pick order (pickFeaturePropertiesfor a Feature,pickConfigPropertiesfor the configuration) and&,<,>unescaped, as in TS. Within a property's object value (a mount object,customizations,portsAttributes, …) the keys are sorted, because a decoded Go map keeps no order, where TS keeps the order they were written in;containerEnvalone keeps TS's order (the array-index keys ascending, then the others as written). An entry given in another order (a base image's label written by another tool) is likewise rewritten in pick order. The parsed metadata is the same; only the label's bytes differ (internal/imagemeta/testdata/ts-oracle/imagemeta.jsonrecords both cases). - Command results write nested objects with their keys sorted. TS writes each result
with
JSON.stringify, so properties come in insertion order. Go matches that order for the results ofup,build,set-up,run-user-commandsandread-configuration, forfeaturesConfiguration, for the top level ofconfiguration(the document's order, withcustomizationsmoved from deprecated properties and thenconfigFilePathlast) and for the top level ofmergedConfiguration(the configuration withoutmergeConfiguration'sreplaceProperties, then the properties it sets). Objects nested deeper are written with sorted keys, because a decoded Go map keeps no order: a configuration'scontainerEnvorcustomizations, merged objects such asremoteEnvorportsAttributes, and each Feature infeaturesConfiguration.featureSets, which TS writes in itsdevcontainer-feature.jsonorder. The parsed values are the same; the parity matrix'skey_ordercases hold the orders that match.
The entries below fix upstream bugs that are still present upstream; each names the upstream issue it fixes.
- GPU detection also asks
nvidia-container-cli. With--gpu-availability detect(the default), TScheckDockerSupportForGPUenables the GPU wheneverdocker infolists thenvidiaruntime. The runtime is often installed on machines without a GPU (cloud images carry it), andupthen fails starting a container with--gpus all(upstream #319). This CLI also requiresnvidia-container-cli infoto succeed when that binary is onPATH; without the binary it decides fromdocker infoalone, as TS does (TestCheckGPUAvailability_NvidiaCLIProbe,internal/cli/upstream_fixes_test.go). build [path]builds the workspace atpath. TS declares the positionalbuild [path]butdoBuildnever reads it, sodevcontainer build ./projectbuilds the current folder (upstream #8). This CLI takes the path as the workspace folder when--workspace-folderis not given; the flag wins when both are (TestBuildPositionalPathIsTheWorkspace,internal/cli/build_repo_held_test.go).- A Dockerfile's own
<Dockerfile>.dockerignoreapplies when the CLI builds a Dockerfile it generated. BuildKit reads the ignore file next to the Dockerfile it is given. TS builds a Dockerfile it writes in a folder of its own (to add Features and the metadata label, also for a Compose service), so the user's.devcontainer/Dockerfile.dockerignoreis not read and the files it excludes are sent in the build context (upstream #969). This CLI copies the ignore file next to every Dockerfile it generates (TestCopyDockerignore,TestBuildAndExtendComposeDockerfileServiceininternal/cli/compose_up_held_test.go,TestBuildDockerfileKeepsTheSiblingDockerignore, andTestE2E_SiblingDockerignoreininternal/cli/e2e_fixes_test.go). - A
LABEL devcontainer.metadatain the Dockerfile is kept. TS builds the image's metadata label from the base image's label and the configuration and writes it over the image, so aLABEL devcontainer.metadata=…written in the built stage of the user's Dockerfile is lost (upstream #1225). This CLI merges that stage's entries between the base image's and the configuration's (TestStageLabelsDevcontainerMetadataandTestStageLabelsReadsTheNamedStageininternal/docker,TestBuildDockerfileMetadataLabelininternal/cli/dockerfile_build_held_test.go). build --labelapplies to a Docker Compose configuration.docker compose buildtakes no labels, and TS drops--labelfor a Compose configuration without a word (upstream #930). This CLI applies the labels with one more build,FROM <image>, tagged with the--image-namevalues or, without them, the image's own name (TestE2E_ComposeBuildLabel,internal/cli/e2e_fixes_test.go).build --labelapplies to a Dockerfile configuration. TS passes--labelonly to the build that extends an image configuration with Features (updateImage); the singledocker buildof a Dockerfile configuration (buildAndExtendImage) drops it without a word. This CLI passes each--labelto that build, as it does for an image and for a Docker Compose configuration, so that--labelnever goes unapplied without a word (TestBuildDockerfileBuildFlags,internal/cli/dockerfile_build_held_test.go).features generate-docsandtemplates generate-docsdocument the folders they are given. TS takes every entry of the project folder as an item folder: pointed at a single Feature or Template folder it looks for metadata under each of its files and documents nothing, and each top-level file (aREADME.md, aLICENSE) is reported with anot found … Skipping...warning (upstream #876). This CLI documents the folder itself when it holdsdevcontainer-<feature|template>.json, and otherwise reads only its sub-folders, skipping files without a word (TestGenerateDocs_SingleFeatureFolder,TestGenerateDocs_IgnoresTopLevelFiles,internal/cli/collection_commands_test.go). The metadata is also read strictly: TS'sjsonc.parserecovers from syntax errors and documents whatever it could read, while this CLI logsFailed to parse <file>and skips an item whose metadata is not valid JSONC; comments and trailing commas are accepted as in TS (TestGenerateDocs_SkipsMetadataThatIsNotValidJSONC), for the same reason a devcontainer.json that is not valid JSONC fails to load.- Host variables in a Feature's
containerEnvare resolved in the image'sENVlines. TS writes a Feature'scontainerEnvinto the Features Dockerfile as given, so"containerEnv": {"CACHE": "${localEnv:HOME}/.cache"}becomesENV CACHE="${localEnv:HOME}/.cache", which BuildKit rejects (unsupported modifier (:H) in substitution) (upstream #308). This CLI resolves${localEnv:…},${localWorkspaceFolder}and the other host variables before it writes theENVline, as it does for the configuration's owncontainerEnv(TestExtendImageWithFeaturesResolvesFeatureHostVarsininternal/cli/feature_install_nodocker_held_test.go,TestComposeFeatureSetsininternal/cli/compose_up_held_test.go). - The Linux default credential helper is
secretservice. When the Docker config has noauths,credHelpersorcredsStorefor any registry, TS falls back todocker-credential-passwhenpassis onPATHand otherwise todocker-credential-secret, a binary that does not exist, so the fallback never finds a credential. This CLI falls back todocker-credential-secretservice, the libsecret helper; thepasschoice is unchanged (TestPlatformDefaultHelperFollowsPass,TestDockerConfigWithAuthSkipsPlatformDefaultininternal/oci/auth_held_test.go,TestDefaultCredentialHelperNameininternal/oci/auth_helper_test.go). - A credential helper that does not answer within 15 seconds is skipped. TS waits
for a helper for as long as it runs, so a helper blocked on a locked keyring or an
unanswered prompt hangs the command. This CLI stops the helper after 15 seconds, warns
(
Credential helper "<name>" timed out after 15s for "<registry>"; skipping it …) and goes on without that credential (GITHUB_TOKENor anonymous access) (TestCredentialHelperThatHangsTimesOut,internal/oci/auth_held_test.go). up --remove-existing-containerremoves the container first. TS removes the existing container only when it looks for it to reuse, after the disallowed-Features check and afterinitializeCommandhas run, so a failinginitializeCommandor a disallowed Feature leaves the old container in place although the flag asked for it to go (upstream #844). This CLI removes it before both (TestE2E_RemoveExistingBeforeInitialize,internal/cli/e2e_fixes_test.go).updateRemoteUserUIDchanges the UID of a user without a home folder. The TSupdateUID.Dockerfilerunschown -R $NEW_UID:$NEW_GID $HOME_FOLDERunconditionally and unquoted, so a remote user whose home folder does not exist (a system user created with--no-create-home) fails the image update, and a home path with spaces is split (upstream #109). This CLI's Dockerfile chowns the home folder only when it exists, and quotes it (TestE2E_UpdateUIDSystemUser,internal/cli/e2e_fixes_test.go).
- Platform: Linux only (amd64 / arm64). Windows and macOS are not targets — no
runtime/E2E/release/
windows-latest/ConPTY lane. Theplatform="win32"logic is kept solely for parity with the oracle. (arm64 runtime is validated via a non-gating, QEMU-emulated experimental job.) - Runtime: Docker only. Podman is not supported (no parity guarantee or test). TS's
Podman-only behavior is not implemented, among it
--security-opt label=disableon a build with BuildKit build contexts whengetenforceis notDisabledandpodman inforeports SELinux enabled (isUsingSELinuxLabels), and--security-opt label=disableand--userns=keep-idonpodman run. With Docker, TS adds neither. - Compose: v2 only (
docker compose). Compose v1 (docker-compose) is not supported. exec: inherited terminal (docker exec -itinherits the controlling terminal), no self-managed PTY. The 128+N contract comes from the child process. Interactivedocker execis deliberately kept as a shell-out.- Docker Go SDK:
github.com/moby/moby/{client,api}(the v29 "options-in, result-out" surface), replacing the deprecatedgithub.com/docker/docker. The top-levelgithub.com/moby/moby(v2) module is an internal implementation detail and is deliberately not a dependency. Requires Docker Engine API ≥ v1.44 (Docker v25+). - OCI image:
ghcr.io/spin-stack/devcontainer-cli(source repogithub.com/spin-stack/devcontainer-cli), distroless/static, non-root. Contract: it is a distribution/self-contained-operations artifact, not a batteries-included runner. It carries only the static/devcontainerbinary (plus CA certs), so it serves two uses: (1) extracting the binary onto a host, and (2) running the commands that work fully in-process —read-configuration,features/templatesauthoring, OCI push/pull,--version. The commands that shell out (up/build/exec/Compose) need thedocker/buildx/docker composebinaries and a reachable daemon, which the distroless image deliberately does not bundle; run those on a host that has Docker, ordocker cpthe binary out. The release smoke test (--version) validates the distribution use, not the docker-dependent commands (which cannot run from this image by design). - Self-containment stance. Container/engine operations, Docker-context resolution and
git-root detection run in-process (Go libraries / stdlib), and lifecycle hooks run via
the Docker exec API rather than
docker exec. The following are kept as shell-outs on purpose:docker buildx build(buildx feature breadth + the user's builder/context; a library would regress buildx or pull the heavy buildkit client),docker compose(its output is not in the compared stream, so a library adds large deps for zero parity gain), interactivedocker exec -it, and the credential-helper protocol (external executables by design). These shell-outs receive the command'scontext.Context(viaexec.CommandContext), so a deadline/cancel — orCtrl-C— aborts an in-flightbuild/composesubprocess rather than orphaning it. One consequence is visible in the log: TS runs every engine operation through thedockerCLI (plainExec) and logs each one as aStart: Run: docker …/Stop (N ms): Run: docker …pair (startandstoprecords with--log-format json). An operation this CLI performs through the Docker Engine SDK (inspecting, listing, creating, starting and removing containers, lifecycle hooks) writes no such pair; the shell-outs above log aRun: …line.
Known gaps that are deliberately not closed; each is a conscious trade-off, not an oversight.
-
Which of several failing parallel lifecycle commands is named. When more than one command of an object-form hook (
"postCreateCommand": {"a": ..., "b": ...}) fails, TS names the first failure in the object's key order (Promise.allSettled+find); the command maps decode without key order here, so the first failure in sorted order is named. With one failing command — the common case — the description (b of postCreateCommand from devcontainer.json failed.) is identical. Keeping key order would mean an ordered representation for every lifecycle command through the config, metadata and merge;TestHookErrorDescribesTheCommandAndItsOriginpins the current choice. -
A lone surrogate in a string is written as U+FFFD. Command results are written as
JSON.stringifywrites them:&,<,>, U+2028 and U+2029 as they are, not escaped asencoding/jsonwould (read-configuration.json-stringify-escapes-success). A configuration string holding a lone UTF-16 surrogate escape ("\ud800") is the exception: a Go string cannot carry one, so the JSON decoder replaces it with U+FFFD when the file is read, and the result holds that character where TS writes the escape back. -
Byte-for-byte tarball parity is unattainable because of
mtimedifferences; tarball contents are compared by parsed structure, not raw bytes. -
Cloud registry auth matrix is out of default CI scope. The hermetic auth paths (401→bearer, credential-helper protocol,
DEVCONTAINERS_OCI_AUTH,GITHUB_TOKEN) are unit-tested; a real ACR (identity/refresh) / ECR / authenticated-GHCR matrix is secrets-gated and non-blocking. The platform default credential helper, used when the Docker config names none, is the Linux one only (pass, elsesecretservice; see The Linux default credential helper issecretserviceabove). -
TS→Go metadata interop is exercised by
TestMetadataInteropTSToGo, which builds the same configuration with the oracle and with this CLI and compares the mergeddevcontainer.metadatalabels; it runs when the oracle is compiled (task reference) and Docker is available. The Go→Go round-trip and whitespace invariance are unit-tested. -
Version banner differs cosmetically: this CLI reports a git hash / CalVer, TS a semver, and the banner box width depends on the version length. Verbose commands (features-test / features-info) are compared via
exit_code/ stderr rather than the banner. -
Legacy Feature fallback via GitHub Releases is not implemented; feature resolution is OCI-first (the supported path for v2 features). A configuration that references one fails with the resolution error (read-configuration included) instead of silently omitting the Feature.
-
Mermaid node ids differ from TS.
features info dependenciesandfeatures resolve-dependenciesprint the dependency graph as a Mermaid flowchart. TS names each node with the first six hex digits of the SHA-256 ofJSON.stringifyof the whole graph node (generateMermaidNode), which includes the resolved Feature set and its metadata and the node'sdependsOn/installsAfterlists, in the order a JavaScript object keeps. This CLI hashes the node's type, user Feature id, options, round priority and aliases instead, since reproducing TS's bytes would mean reproducing that whole object in insertion order. The graph's nodes, edges and labels are the same and each edge points at its node's own id; the parity harness replaces the ids with<NODE>(reMermaidNodeinnormalizeStringOpts,internal/cli/parity_matrix_test.go) forfeatures-info.dependencies-textandfeatures.resolve-dependencies-success. -
A Feature or Template reference whose digest fails validation is refused. For
<ref>@sha256:<hex>where<hex>does not match the version pattern (empty, or starting with-), TSgetReflogsDigest for input '…' failed validationand still returns the reference, where its other checks (the digest's form and algorithm, the tag) return no reference. This CLI refuses the reference (digest "…" does not match expected pattern), because accepting it would weaken validation; a valid digest behaves as in TS. Theinternal/oci/testdata/ts-oracle/ref.jsongolden records both cases (ghcr.io/x/y/z@sha256:,ghcr.io/x/y/z@sha256:-bad), checked byTestTSOracleRef.