Skip to content

fix(features): enforce lockfile pins on baked features and empty configs - #153

Merged
bilby91 merged 2 commits into
mainfrom
fix/lockfile-review-findings
Nov 4, 2026
Merged

bilby91 merged 2 commits into
mainfrom
fix/lockfile-review-findings

Conversation

@bilby91

@bilby91 bilby91 commented Nov 4, 2026

Copy link
Copy Markdown
Member

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.metadata label 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 resolvedRef into the label since v0.1.0. ParseMetadataLabel dropped 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.

  • Keep the artifact: config.FeatureMetadata.ResolvedRef now survives parsing and rebuilds.
  • Read the lockfile first: layerFeatures reads it before matching the label. markAlreadyInstalled accepts a baked feature only if featureLock.acceptsBaked agrees:
    • its artifact is the pinned one; or
    • with no pin, its artifact is recorded, and it then gets locked.
  • Everything else is fetched: a different or unrecorded artifact is fetched at its pin and installed over the base image.
  • Commit: an accepted baked feature keeps its pin rather than being re-locked from the label, because the label doesn't record 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 their id is the full reference, not the bare id this path compares.

2. Removing the last feature skipped the lockfile (MEDIUM)

Problem: layerFeatures returned before touching the lockfile when no features were declared.

Fix: with no features, commitNoFeatures still reconciles an existing lockfile:

  • default and upgrade modes empty it;
  • frozen mode reports the stale entries;
  • with no lockfile, nothing is created, as with the reference CLI.

Testing

  • TestLockfile_BakedFeature covers six cases: pinned artifact, other artifact, unrecorded artifact, no pin (locks the baked artifact), no pin in frozen mode, and disabled mode.
  • TestLockfile_NoFeatures covers stale entries in frozen and default modes, and that no lockfile gets created.
  • TestParseMetadataLabel_ResolvedRefSurvivesRebuild checks the digest survives parsing and a rebuild on top.
  • Regression check: with acceptsBaked and commitNoFeatures reverted to the old behaviour, these tests fail.
  • Fixture update: TestUp_PrebakedImageSkipsFeatureFetch's label fixture now carries a resolvedRef, like real labels do.
  • Suites: go vet, lint and go test -race ./... pass. The pre-baked, metadata, build and lockfile integration tests pass against a local daemon.

🤖 Generated with Claude Code

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>

@dap-code-review-by-crunchloop dap-code-review-by-crunchloop Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lockfile.go Resolved
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>

@dap-code-review-by-crunchloop dap-code-review-by-crunchloop Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lockfile.go
Comment on lines +87 to +88
pin, pinned := l.pin(f)
return !pinned || feature.CheckIntegrity(f, feature.Fetched{ResolvedRef: baked.ResolvedRef}, pin) == nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@bilby91
bilby91 merged commit ceaba20 into main Nov 4, 2026
10 checks passed
bilby91 added a commit that referenced this pull request Nov 4, 2026
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>
@bilby91
bilby91 deleted the fix/lockfile-review-findings branch November 4, 2026 20:33
bilby91 added a commit that referenced this pull request Nov 5, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant