Skip to content

feat(vscode): verify private registries with local Docker credentials - #39

Merged
CptSchnitz merged 6 commits into
masterfrom
ticket-22/local-docker-credentials
Sep 16, 2026
Merged

CptSchnitz merged 6 commits into
masterfrom
ticket-22/local-docker-credentials

Conversation

@CptSchnitz

Copy link
Copy Markdown
Collaborator

What

Implements #22. A developer already logged in with docker login now 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:

  • Plaintext auths entries, decoded and sent as basic credentials.
  • The global credsStore, invoked as a docker-credential-* subprocess.
  • Per-registry credHelpers, same mechanism.
  • Identity tokens, exchanged at the registry's OAuth token endpoint with a refresh-token grant.

That last one is not optional. An Azure Container Registry auths entry carries both an auth field and an identitytoken, and the auth decodes to a null-GUID username with an empty password — a placeholder, not a credential. A client that reads auth first sends useless basic credentials and collects a 401 from what is, for this organisation, the primary registry. Reading identitytoken first, 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-login verdict carrying the host, which is what makes an actionable prompt possible.

missing-credential splits into needs-login and authentication-failure, because only the first is actionable. The host rides on the needs-login arm 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/vscode is a notification naming the host, an action opening a terminal on docker 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

a1f6e15 fixes a false positive in the existing existence check that has been there since #31. A manifest GET against docker.io is redirected to Docker's marketing site, which answers 200, and fetch follows redirects — so every Docker Hub image came back as existing, missing tags included. 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 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.io while 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 put registry-1.docker.io after 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

  • Docker picks exactly one credential store per registry. A credHelpers entry naming a registry replaces the global credsStore rather 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 credential docker pull would not send — a quieter kind of wrong than a failure, because it looks like agreement with the developer's shell while being something else.
  • The bearer challenge's realm is chosen by whoever answered the manifest request, and the request built from it carries the credential. An http:// realm would have 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, with loopback exempted so a local registry — no certificate, nothing leaving the machine — keeps working. Refusing costs an authentication-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:

  • Basic challenge handling. Verify Helm values image references against container registries #17 specifies only the bearer-token challenge flow. A plain registry:2 answers with Basic, and the branch is three lines.
  • DOCKER_CONFIG support 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 through checkImageExistence with 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 into credentials.ts or authorize.ts.
  • The ACR ordering rule is asserted by a test pairing 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 whichever order the code reads in.
  • 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.ts gained the tests it never had: DOCKER_CONFIG resolution, an absent config reading as "no config", the helper-name guard refusing a; rm -rf /, and a missing helper rejecting so the chain reads it as a miss.
  • Mutation-checked rather than assumed: breaking the Docker Hub endpoint mapping, the store precedence, the realm guard, and the once-per-session prompts wiring each fails exactly its own test and nothing else.
  • Full workspace format, lint, type-check, build, test, package via turbo, plus knip, all pass.

Known gaps

🤖 Generated with Claude Code

CptSchnitz and others added 6 commits September 16, 2026 10:00
…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>
@CptSchnitz
CptSchnitz merged commit bcc154f into master Sep 16, 2026
6 checks passed
@CptSchnitz
CptSchnitz deleted the ticket-22/local-docker-credentials branch September 16, 2026 13:22
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.

Local Docker credentials end to end

1 participant