Repository navigation
fix(features): enforce lockfile pins on baked features and empty configs - #153
Conversation
Two findings from the review of #146: - A feature the base image's label satisfied was skipped and its existing lock entry copied, so frozen mode accepted a base image built from another artifact, or one the lockfile had no entry for. The label already records each feature's resolvedRef, but ParseMetadataLabel dropped it (and so did carrying base entries into a rebuilt label). Keep it on FeatureMetadata, read the lockfile before matching the label, and accept a baked feature only when its artifact is the pinned one or, with no pin, a recorded one, which is then locked. Anything else is fetched at its pin and installed. LockfileDisabled keeps the label-only match. - layerFeatures returned before the lockfile when no features were declared, so removing the last feature left its entry behind and frozen mode accepted it. Reconcile an existing lockfile then too: default and upgrade empty it, frozen reports the stale entries. Without a lockfile nothing is created, as with the reference CLI. The pre-baked hot path is unchanged for images this library built, whose labels have recorded resolvedRef since v0.1.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review — 131bbeb · ADVISORY
Upgrade-mode correctness: the lockfile fix still lets an already-baked artifact short-circuit resolution in LockfileUpgrade, then records that artifact as the new pin, so upgrade can downgrade the lock instead of refreshing it.
| Finding | Anchor | |
|---|---|---|
| 🟡 MEDIUM | Upgrade mode can replace a newer pin with an older baked artifact core/correctness |
lockfile.go:83 |
What I reviewed
Coverage: Reviewed the exact requested commit 131bbebdabba1e47af1142b3a1f8492f28f2b89b (one commit, nine changed files). Worked correctness and interface-contract behavior for baked-feature matching, lockfile reconciliation, and no-feature configurations; checked their consumers in up.go and build.go, the lockfile integrity implementation, and relevant unit tests. Checked design and changelog claims and repository guidance at base 25711e1bb5b50e4438cf3e05d3ef993f6a7a4e1a; .dap/code-review.yml and AGENTS.md are absent, so the repository mechanism guidance in .dap/review/engineering.md was used alongside the core prompt. The change is backend-neutral and introduces no applicable state transaction, retry path, or user-facing feature needing evidence capture. Runtime/tests were not executed, per review constraints.
ADVISORY — findings worth reading, none of them blocking. Reply in a finding's own thread to argue it. On the pull request I answer @dap-code-review-by-crunchloop review, pause and resume.
acceptsBaked found no pin in LockfileUpgrade (pin ignores the lockfile there) and so accepted any recorded baked artifact, which commit then locked: an upgrade could replace a newer pin with the older artifact the base image carries (DAP review finding 3902 on #153). Upgrade mode now accepts no baked feature, so each reference is resolved afresh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review — 67a01cc · ADVISORY
One MEDIUM interface-contract defect remains: accepting an unpinned baked feature can write a lock entry without its declared dependsOn metadata. The earlier upgrade-mode finding is resolved on this head because upgrade mode rejects baked matches and fetches the requested feature afresh.
| Finding | Anchor | |
|---|---|---|
| 🟡 MEDIUM | Unpinned baked features skip the lockfile's dependency metadata core/correctness |
lockfile.go:88 |
What I reviewed
Coverage: Reviewed exact head 67a01cce99a1b727d28a081efbe87caf375d9e52 (2 commits, 9 changed files). Checked correctness and interface-contract behavior in baked-feature matching, lockfile generation, and no-feature reconciliation; compared the lockfile shape with the reference CLI and examined the updated tests. Checked optional metadata-label compatibility: older labels without resolvedRef fall back to fetch, and older readers can ignore the added field. No .dap/code-review.yml exists at the governing base SHA; applied the repository guidance in .dap/review/engineering.md alongside the core review dimensions. Did not execute tests or the system under review. Redelivery, concurrency, and resource-lifecycle dimensions do not apply to this change.
ADVISORY — findings worth reading, none of them blocking. Reply in a finding's own thread to argue it. On the pull request I answer @dap-code-review-by-crunchloop review, pause and resume.
| pin, pinned := l.pin(f) | ||
| return !pinned || feature.CheckIntegrity(f, feature.Fetched{ResolvedRef: baked.ResolvedRef}, pin) == nil |
There was a problem hiding this comment.
🟡 MEDIUM · core/correctness
Unpinned baked features skip the lockfile's dependency metadata
The serialized lockfile is consumed by the reference-compatible feature lockfile path: at devcontainers/cli v0.87.0 src/spec-configuration/lockfile.ts:26-35 writes each feature's declared dependsOn, and containerFeaturesOrder.ts:414-435 resolves those dependencies while preparing a future feature install. In this repository feature.Lock at feature/lockfile.go:155-164 serializes only f.Metadata.DependsOn; the accepted baked metadata has no such field, and Lockfile.Equal/Changed compare it at feature/lockfile.go:113-124.
Fix — When an unpinned baked feature is accepted, retain its declared dependency list from its feature manifest (for example by resolving the manifest or preserving the relevant dependency data in the image label) before generating the lock entry; alternatively fetch the feature artifact to build a complete lock entry.
Witness
At lockfile.go:87-88 acceptsBaked returns true for a baked artifact when no lock pin exists. markAlreadyInstalled assigns the parsed label entry to f.Metadata; metadataEntry does not include dependsOn, while fetched feature metadata does (feature/metadata.go:23-38, feature/parse.go:63-65,92-98). commit then passes this metadata to feature.Lock, whose DependsOn is constructed from f.Metadata.DependsOn (feature/lockfile.go:155-164), and writes the resulting entry. A real feature declaring dependsOn thus gets an empty dependsOn in newly generated lockfile after its first baked-image acceptance without a prior pin. CLI v0.87.0 lockfile generation writes keys from the manifest (/tmp/devcontainers-cli/src/spec-configuration/lockfile.ts:25-35), and its ordering resolver reads those declarations (containerFeaturesOrder.ts:414-435).
There was a problem hiding this comment.
Valid for this commit taken alone. It's resolved by #151, which is stacked on this PR and merges right after it: there, layerFeatures fills in a baked feature's dependsOn before lock.commit runs. It comes from the label, which now records dependsOn, or for a label written before that, from the feature's metadata fetched at its recorded artifact. So feature.Lock builds the entry from the full list. The lockfile is unreleased, so main won't ship with the gap. I'm adding a test on #151 that asserts an unpinned baked feature's lock entry carries its dependsOn.
There was a problem hiding this comment.
I can't settle this from the review record alone — Settling this requires reading the stacked branch #151 — layerFeatures, the label schema, and the new test — which is not in this record; the finding stands on the sequence this diff shows.
To have me read the code and take another look, comment @dap-code-review-by-crunchloop review. I also understand @dap-code-review-by-crunchloop pause and @dap-code-review-by-crunchloop resume.
The DAP review of #153 (finding 3904) noted a baked feature accepted without a pin was locked from its label entry, which then had no dependsOn. This change fills it in before commit; pin it with a test for a label that records dependsOn and for an older one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(docker): pre-pull the Dockerfile syntax frontend image (#150) * fix(docker): pre-pull the Dockerfile syntax frontend image BuildImage builds through ImageBuild with no BuildKit session, and BuildKit before v0.31 (Docker Engine before 29.6) refuses registry access without one, even for anonymous public pulls: a bearer challenge with no session fails with "no active sessions". FROM images were already pre-pulled through the classic pull API for this reason; a `# syntax=` frontend was not, so any user Dockerfile naming one failed before parsing (#148). Pre-pull the frontend too: the BUILDKIT_SYNTAX build arg, else the syntax parser directive, read as BuildKit's parser.DetectSyntax reads it (leading directive lines only, # or // comments, after an optional BOM and shebang; the image is the value's first word). Verified against a Docker 29.5.0 daemon (containerd image store): the new integration test fails there with the reported error without this change and passes with it. Docker 29.6+ resolves anonymously without a session, so it passes there either way. Fixes #148. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(docker): pre-pull a frontend named by a JSON syntax document BuildKit's parser.DetectSyntax falls back, after the # and // directive forms, to a JSON object with a string "syntax" member, the input of custom frontends that take JSON, and uses the whole value as the frontend image. syntaxFrontend left that form out, so its frontend was not pre-pulled and hit the session-less resolution failure on engines before 29.6 (DAP review finding 3891 on #150). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: bilby91 <2201079+bilby91@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(features): enforce lockfile pins on baked features and empty configs (#153) * fix(features): enforce lockfile pins on baked features and empty configs Two findings from the review of #146: - A feature the base image's label satisfied was skipped and its existing lock entry copied, so frozen mode accepted a base image built from another artifact, or one the lockfile had no entry for. The label already records each feature's resolvedRef, but ParseMetadataLabel dropped it (and so did carrying base entries into a rebuilt label). Keep it on FeatureMetadata, read the lockfile before matching the label, and accept a baked feature only when its artifact is the pinned one or, with no pin, a recorded one, which is then locked. Anything else is fetched at its pin and installed. LockfileDisabled keeps the label-only match. - layerFeatures returned before the lockfile when no features were declared, so removing the last feature left its entry behind and frozen mode accepted it. Reconcile an existing lockfile then too: default and upgrade empty it, frozen reports the stale entries. Without a lockfile nothing is created, as with the reference CLI. The pre-baked hot path is unchanged for images this library built, whose labels have recorded resolvedRef since v0.1.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(features): re-resolve baked features in lockfile upgrade mode acceptsBaked found no pin in LockfileUpgrade (pin ignores the lockfile there) and so accepted any recorded baked artifact, which commit then locked: an upgrade could replace a newer pin with the older artifact the base image carries (DAP review finding 3902 on #153). Upgrade mode now accepts no baked feature, so each reference is resolved afresh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: bilby91 <2201079+bilby91@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(features): install features reachable only through dependsOn dependsOn only ordered features that devcontainer.json declared anyway: an undeclared dependency was silently skipped, so a feature relying on one installed without it (#147). The spec makes dependsOn a hard dependency, installed recursively with the options it names. - layerFeatures' fetch loop appends each fetched feature's dependsOn requirements and fetches them in turn: lockfile pinning, option validation and defaults apply as for declared features, and they are locked under their dependsOn key as the reference CLI does. - Spec equality decides duplicates: same content (digest) and same options once defaults apply. A repeat of a key with the same options is skipped before fetching, which also ends cycles; the same content under another reference is dropped after. One feature with different options is installed once per set, and topoSort maps an id to all its entries, pointing dependsOn edges at the one with the requested options. - A dependency the base image's label satisfies is not fetched or installed again: bakedFeature, factored from markAlreadyInstalled, includes the lockfile's check of the artifact the label records. Its own dependencies are still followed, as are a declared baked feature's, since an image built before this change lacks them. Labels now record each feature's dependsOn ({} when none); for a label written before that, the baked feature's metadata is fetched at its recorded artifact to find them. - A dependency dropped as a duplicate under another reference is still locked under its own key. - The compose inputs hash also covers a local dependency's content, which the declaring feature's content does not; projects without one hash as before. - OCI references are lowercased before parsing, as the CLI's getRef does: published dependsOn keys such as ghcr.io/codspace/dependson/A failed to parse. The integration test builds the CLI's own lockfile-dependson fixture in frozen mode against its expected lockfile. Closes #147. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(features): record the baked match's option gap for dependencies The DAP review of #151 (finding 3903) notes a baked dependency is reused whatever options it was requested with. The pre-baked match has never compared options, for declared features either; record the limitation in §5.1 and track the fix to the match itself in #154. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(features): lock an unpinned baked feature with its dependsOn The DAP review of #153 (finding 3904) noted a baked feature accepted without a pin was locked from its label entry, which then had no dependsOn. This change fills it in before commit; pin it with a test for a label that records dependsOn and for an older one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(features): confine local dependsOn paths The DAP review of #151 (findings 3912, 3919, 3917) showed a fetched feature's dependsOn could name a local path, absolute or escaping with .., which layerFeatures resolved against devcontainer.json's directory, copied into the build context and ran: published metadata choosing a host directory to execute. The compose inputs hash also missed such a dependency, since it follows local dependencies from declared local features only. A local dependsOn key is now followed only from a local feature, and only as a relative path that resolves, symlinks included, inside the directory of devcontainer.json, as the reference CLI requires of local features; anything else fails Up and Build. With remote features unable to reach local dependencies, the compose hash's traversal covers every one. Declared local features keep their existing resolution. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(features): never take a dependsOn dependency from the base image The DAP review of #151 (finding 3913) showed a dependency the base image's label listed was reused whatever options its dependsOn entry asked for: the pre-baked match compares id and version only, and those options are the feature author's, which no user config could route through that match before. Dependencies are now always fetched and installed; a declared baked feature still is not reinstalled, and its dependencies are installed. Until #154 adds option matching, a pre-baked image whose features have dependencies reinstalls them on every build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: bilby91 <2201079+bilby91@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Fixes the two findings the DAP review raised on #146 (dap-finding 3890 HIGH, 3887 MEDIUM). #146 was merged while that review was still BLOCKING, so both issues are on
main. The lockfile is unreleased (still under[Unreleased]).1. Frozen builds trusted pre-baked features (HIGH)
Problem: a feature the base image's
devcontainer.metadatalabel satisfied was skipped, and its existing lock entry was copied over. Frozen mode therefore accepted a base image built from a different artifact, or one the lockfile had no entry for.The data was already there: this library has written every feature's
resolvedRefinto the label since v0.1.0.ParseMetadataLabeldropped it, and so did carrying base entries into a rebuilt label, despite that path's "lossless" comment.Fix: reuse the existing label match and add the lockfile as a second condition.
config.FeatureMetadata.ResolvedRefnow survives parsing and rebuilds.layerFeaturesreads it before matching the label.markAlreadyInstalledaccepts a baked feature only iffeatureLock.acceptsBakedagrees:dependsOn.LockfileDisabled: keeps the old label-only match.Effect on the pre-baked fast path: none for images this library built, since they record
resolvedRef. Labels from other tools record no artifact. The reference CLI's labels never matched here anyway, because theiridis the full reference, not the bare id this path compares.2. Removing the last feature skipped the lockfile (MEDIUM)
Problem:
layerFeaturesreturned before touching the lockfile when no features were declared.Fix: with no features,
commitNoFeaturesstill reconciles an existing lockfile:Testing
TestLockfile_BakedFeaturecovers six cases: pinned artifact, other artifact, unrecorded artifact, no pin (locks the baked artifact), no pin in frozen mode, and disabled mode.TestLockfile_NoFeaturescovers stale entries in frozen and default modes, and that no lockfile gets created.TestParseMetadataLabel_ResolvedRefSurvivesRebuildchecks the digest survives parsing and a rebuild on top.acceptsBakedandcommitNoFeaturesreverted to the old behaviour, these tests fail.TestUp_PrebakedImageSkipsFeatureFetch's label fixture now carries aresolvedRef, like real labels do.go vet, lint andgo test -race ./...pass. The pre-baked, metadata, build and lockfile integration tests pass against a local daemon.🤖 Generated with Claude Code