feat(vscode): verify private registries with local Docker credentials - #39
Merged
Merged
Conversation
…ntials A developer logged in with `docker login` now gets real verdicts for that registry's images instead of a blanket unverifiable. One that is not logged in gets `needs-login` carrying the host, which is the piece a caller needs to offer the fix rather than just reporting a failure. The whole credential chain is here because the shallow reading of "use local Docker credentials" silently fails on the registry most likely to matter. An Azure Container Registry `auths` entry carries both an `auth` field and an `identitytoken`, and that `auth` decodes to a null-GUID username with an EMPTY password. It is a placeholder, not a credential. A client that reads `auth` first therefore sends useless basic credentials and collects a 401 from what is, for this organisation, the primary registry — so the one registry the feature fails on would be the one everybody uses. Reading `identitytoken` first, and spending it as an OAuth2 refresh-token grant rather than as a password, is what makes that case work, and it costs nothing anywhere else. That ordering is asserted by a test that pairs an identity token with a usable password, so only the order can decide it; the ACR entry alone cannot prove the rule, because its empty password reaches the identity token whatever the order. Resolution follows Docker's own precedence — per-registry helper, global store, then the plaintext entry — falling through on each miss, because a miss is the normal state of a keychain that has only ever been asked about one registry. Config keys are normalized to a bare host before matching, since `docker login` writes Docker Hub under `https://index.docker.io/v1/` while a repository names it `docker.io`; a helper is still handed the original key, which is what it stores under. `missing-credential` splits into `needs-login` and `authentication-failure` because only the first is actionable. The registry host rides on the `needs-login` arm specifically, so a "log in to X" prompt is unbuildable without a host and no other reason can pretend to have one. The invariant is untouched: both are unverifiable, and unverifiable still produces no diagnostic. A second 401 after presenting a credential stays an authentication failure rather than becoming a not-found, because a registry may answer 401 for a repository the caller is not allowed to know about. Registries outside a known-public table are never contacted anonymously. The anonymous attempt would disclose a private repository name to whoever answers and buy nothing, since its 401 says no more than the config already did. The helper name comes out of a config file and is interpolated into a command name, so it is validated at the spawn boundary rather than trusted. `packages/oci-registry` is the only workspace touched; `apps/vscode` does not compile until its own unit lands. Refs #22 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…xt realms Two corrections to the credential chain, both found in review. Docker picks exactly one credential store per registry: a `credHelpers` entry naming the registry replaces the global `credsStore` rather than being tried ahead of it, and whichever one applies falls through on a miss to the plaintext `auths` entry, never to the other store. Trying both, as the chain first did, lets this package verify an image with a credential `docker pull` would not have sent — a quieter kind of wrong answer than a failure, because it looks like agreement with the developer's shell while being something else. The `realm` a bearer challenge names is chosen by whoever answered the manifest request, and the request built from it is the one carrying the credential. An `http://` realm therefore put a password or a refresh token on the wire in the clear at the say-so of a response header. Only `https` is accepted now, with loopback exempted so a local registry — which has no certificate and tells nothing to anyone off the machine — keeps working. Refusing costs an `authentication-failure`, which renders nothing, so being strict here fails silent rather than wrong. Both are asserted by tests that fail against the previous code: the first by counting helper invocations, the second by counting requests, since neither rule is visible in the verdict alone. Refs #22 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`AuthChallenge` and `BasicCredential` describe the inside of the bearer challenge flow and are named by nothing outside the file that defines them. Exporting them made `knip` fail the repo's unused-code check, which it has been doing since the credential chain landed. Refs #22 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tial A registry the developer has never logged in to now says so, once, with the fix attached: a notification naming the host, an action that opens a terminal on `docker login <host>`, and an action that silences that registry for good. A status bar count keeps the total visible after the notification is gone, so "why is half this file unverified" has an answer that does not require remembering a toast. None of this is a diagnostic, deliberately. Not having logged in to a registry is a fact about the developer's machine, not a defect in the values file, and a Problems panel that mixes the two teaches people to stop reading it. The registry package already routes the case to an unverifiable verdict, which renders no error; this commit is only about giving it somewhere to go. Dismissals persist in extension state rather than in settings. A dismissal is not configuration — it records that one developer stopped caring about one registry — and putting it in settings would accumulate that in a file the whole team has checked in, where it would then need explaining. The state is one map from registry to `'prompted' | 'dismissed'`, not a pair of sets, because every rule this surface has is a statement about it. At most once per session is that nothing leaves `'prompted'` except into `'dismissed'`. Never again after a window reload is that `'dismissed'` is what gets persisted and what seeds the map next time. The status bar count is how many entries are `'prompted'`. Both deduplication points are tested, because a values file naming ten images on one private registry is the ordinary case, and ten notifications for it would be the feature's worst behaviour. `report` swallows its own failures for the same reason the mark code does: the open listener fires it without awaiting, so an escaping rejection is an unhandled one, and that takes the extension host down. A check still in flight when the window closes finds the status bar item disposed, which is exactly that case. `MarkKind` loses its export in passing. Nothing outside `marks.ts` names it, and it was already failing the repo's unused-code check before this branch. Refs #22 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ects
A manifest `GET` against `docker.io` is redirected to the marketing site,
which answers `200`. `fetch` follows the redirect, so every Docker Hub
image came back as existing — including tags that do not. Verified against
the live hosts:
https://docker.io/v2/library/nginx/manifests/no-such-tag-xyz
-> 302 -> https://www.docker.com/ -> 200
https://registry-1.docker.io/v2/library/nginx/manifests/no-such-tag-xyz
-> 401
A false checkmark is the one wrong answer this feature cannot survive. The
whole point of marking an image verified is that the mark means something,
and one that appears for every Hub reference means nothing at all — worse
than staying quiet, because it is silent and it looks like success.
Requests now go to `registry-1.docker.io`, which serves the distribution
API and challenges properly, while the verdict keeps naming the registry
the file named. Those are deliberately two different values: the mark
appends a registry only when it differs from the file's, so reporting the
endpoint would append `registry-1.docker.io` to every Hub image on screen.
The test pins both halves separately, because conflating them is how this
comes back.
Hub's three names now live in one module. They were already duplicated
between credential lookup and this fix, and no two of them are
interchangeable: a repository names `docker.io`, `docker login` writes the
credential under `https://index.docker.io/v1/`, and only
`registry-1.docker.io` answers `/v2`.
This predates the branch — it arrived with the first existence check in
4c44c70 — but the branch is what put Docker Hub on the anonymous-request
list and taught the credential chain to find its login, so it is the
branch that made the claim this breaks.
Refs #22
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Non-behavioural cleanups from reviewing the branch, grouped so the feature commits stay readable. `isRecord` was byte-identical in `authorize.ts` and `credentials.ts`, and the same reader lived under two names — `readStringProperty` and `readStringField`. Both now come from one module, which is also the place to say once why these readers never throw: the values they parse are a config file on the developer's machine and a token endpoint's response, neither of which this package controls, and neither of which is worth an exception when it comes back the wrong shape. `RegistryCredential`, `UnverifiableVerdict` and `CheckDependencies` stopped being exported. Nothing outside their own files names them, and knip does not flag exports from a package entry point, so nothing would have caught it later. `verdict.ts` moves to a footer export block, matching the rest of the package. `local-docker-credentials.ts` gains the tests it never had. It is the only file here that really touches the disk and the process table, and the helper-name guard in it is a security boundary — the name comes out of a config file and is interpolated into a command name — so it had no business being the one untested thing. The tests cover `DOCKER_CONFIG` resolution, an absent config reading as "no config" rather than an error, the guard refusing `a; rm -rf /`, and a missing helper rejecting so the chain reads it as a miss. In `login-prompts.ts`, one `registriesIn(wanted)` replaces the two copies of the same filter-and-map. Its parameter is `wanted`, not `state`, because `state` already means the `Memento` in that closure. In `reference-check.ts`, the doc comment for `checkImageReferencesInDocument` had ended up above the `CheckDependencies` interface introduced beneath it, documenting the wrong symbol. Refs #22 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Sep 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Implements #22. A developer already logged in with
docker loginnow gets real verdicts for that registry's images instead of a blanket unverifiable, and one who is not logged in is told once, with the fix attached.The credential chain lands in
packages/oci-registry, reached only through the package's existing single entry point. All four mechanisms are here, because the shallow reading of "use local Docker credentials" silently fails on the registry most likely to matter:authsentries, decoded and sent as basic credentials.credsStore, invoked as adocker-credential-*subprocess.credHelpers, same mechanism.That last one is not optional. An Azure Container Registry
authsentry carries both anauthfield and anidentitytoken, and theauthdecodes to a null-GUID username with an empty password — a placeholder, not a credential. A client that readsauthfirst sends useless basic credentials and collects a 401 from what is, for this organisation, the primary registry. Readingidentitytokenfirst, and spending it as a refresh-token grant rather than as a password, is what makes it work.Registries outside a known-public table are never contacted anonymously. The anonymous attempt would disclose a private repository name to whoever answers and buy nothing, since its 401 says no more than the config already did. Those resolve to a new
needs-loginverdict carrying the host, which is what makes an actionable prompt possible.missing-credentialsplits intoneeds-loginandauthentication-failure, because only the first is actionable. The host rides on theneeds-loginarm alone, so a "log in to X" prompt is unbuildable without a host and no other reason can pretend to have one. The invariant from #17 is untouched: both are unverifiable, and unverifiable still produces no diagnostic.The editor surface in
apps/vscodeis a notification naming the host, an action opening a terminal ondocker login <host>, an action dismissing that registry permanently, and a status bar count. None of it is a diagnostic. Not having logged in to a registry is a fact about the developer's machine, not a defect in the values file, and a Problems panel that mixes the two teaches people to stop reading it. Dismissals persist in extension state rather than settings — a dismissal is not configuration, and it should not accumulate in a file the whole team has checked in.Closes #22
The bug this found, which predates the branch
a1f6e15fixes a false positive in the existing existence check that has been there since #31. A manifestGETagainstdocker.iois redirected to Docker's marketing site, which answers200, andfetchfollows redirects — so every Docker Hub image came back as existing, missing tags included. Verified against the live hosts:A false checkmark is the one wrong answer this feature cannot survive: the whole point of the mark is that it means something, and one that appears for every Hub reference means nothing, silently, while looking like success. The old test asserted the wrong URL, so the suite was holding the bug in place.
Requests now go to
registry-1.docker.iowhile the verdict keeps naming the registry the file named. Those are deliberately two values — the checkmark appends a registry only when it differs from the file's, so reporting the endpoint would putregistry-1.docker.ioafter every Hub image on screen. The test pins both halves separately.It predates this branch, but this branch is what put Docker Hub on the anonymous-request list and taught the credential chain to find its login, so it is this branch that made the claim it breaks.
Two things worth a reviewer's attention
credHelpersentry naming a registry replaces the globalcredsStorerather than being tried ahead of it, and whichever applies falls through on a miss to the plaintext entry, never to the other store. Trying both would let this package verify with a credentialdocker pullwould not send — a quieter kind of wrong than a failure, because it looks like agreement with the developer's shell while being something else.realmis chosen by whoever answered the manifest request, and the request built from it carries the credential. Anhttp://realm would have put a password or a refresh token on the wire in the clear at the say-so of a response header. Onlyhttpsis accepted, with loopback exempted so a local registry — no certificate, nothing leaving the machine — keeps working. Refusing costs anauthentication-failure, which renders nothing, so being strict here fails silent rather than wrong.Two deliberate additions beyond #17
Both are cheap and both are judgement calls a reviewer may want to overturn:
Basicchallenge handling. Verify Helm values image references against container registries #17 specifies only the bearer-token challenge flow. A plainregistry:2answers withBasic, and the branch is three lines.DOCKER_CONFIGsupport and a helper-name guard. The helper name comes out of a config file any process can write and is interpolated into a command name, so it is validated at the spawn boundary rather than trusted.Testing
packages/oci-registry: 32 tests. Every acceptance criterion in Local Docker credentials end to end #22, driven throughcheckImageExistencewith the Docker config contents and the helper subprocess runner injected, per Verify Helm values image references against container registries #17's testing decision — no test reaches intocredentials.tsorauthorize.ts.apps/vscode: 44 tests. The prompt surface, plus wiring assertions that a needs-login registry notifies and raises no diagnostic, and that it notifies once per registry across files rather than once per file.local-docker-credentials.tsgained the tests it never had:DOCKER_CONFIGresolution, an absent config reading as "no config", the helper-name guard refusinga; rm -rf /, and a missing helper rejecting so the chain reads it as a miss.format,lint,type-check,build,test,packageviaturbo, plusknip, all pass.Known gaps
common/nginxand other references naming no host are still unverifiable — nothing resolves a registry for them yet. That is Document registry and the Docker Hub fallback #23 (document-declared registry and the Docker Hub fallback) and Registry override set #25 (the override set).unexpected-responseabsorbs it. The "authentication failure" gap flagged in feat(vscode): checkmark and hover for image reference results #37 is now closed.🤖 Generated with Claude Code