Skip to content

feat(cli): read pnpm's auth.ini for embedded package credentials [RED-891] - #1444

Open
sorccu wants to merge 4 commits into
mainfrom
simo/red-891-pnpm-auth-ini-support
Open

feat(cli): read pnpm's auth.ini for embedded package credentials [RED-891]#1444
sorccu wants to merge 4 commits into
mainfrom
simo/red-891-pnpm-auth-ini-support

Conversation

@sorccu

@sorccu sorccu commented Aug 24, 2026

Copy link
Copy Markdown
Member

Linear: RED-891

pnpm 11 moved pnpm login credentials out of .npmrc into auth.ini in pnpm's global config directory. Embedded package downloads only merged .npmrc files, so a logged-in pnpm user looked unauthenticated and embedding a private package failed with HTTP 404 — npm answers 404, not 401, for packages an unauthorized caller may not see, so the failure read as "this package does not exist".

Read pnpm's auth.ini

Resolves pnpm's config directory the way pnpm does (XDG_CONFIG_HOME/pnpm → macOS ~/Library/Preferences/pnpm~/.config/pnpm → Windows %LOCALAPPDATA%/pnpm/config; PNPM_HOME is deliberately not consulted — pnpm uses it only for data and state directories).

The file is always read; only its precedence depends on the lockfile: above the user .npmrc for pnpm projects, below it for others, mirroring pnpm's own order. Precedence is the load-bearing part — the reported failure was a stale .npmrc token being sent, so reading auth.ini without outranking that token would change nothing. Both end-to-end tests write a different token into each file and assert which one arrives; a fixture with a token in only one file passes under either ordering and cannot detect inverted precedence.

An unreadable auth.ini is skipped rather than fatal: unlike a .npmrc, it belongs to another tool and must not take the whole command down.

Name the credential source on failure

The hint now also fires on 404 (hedged — a 404 can equally mean the package is absent), and says which credential was used and where it came from. With five possible sources, "your credentials were rejected" without naming one leaves the reader as stuck as the bare status code:

Failed to download embedded package '@acme/private@1.2.3' from '…' (HTTP 404).
Credentials were sent but did not grant access, so either the package does not
exist or the credentials do not cover it. They came from
'//registry.npmjs.org/:_authToken' in '/Users/…/.npmrc'.

Both halves of a username/_password pair are reported, since precedence is per key and the halves routinely live in different files — naming only the username points at the half that cannot expire. Credentials embedded in a registry URL are attributed to the URL and to whatever configured it, because axios sends those itself and drops the Authorization header when it does.

Config paths and key names appear in these messages; credential values never do, and the tests assert that.

Reviewer notes

  • loadNpmrcConfig returns {config, sources, unreadable, origins} instead of a bare map, and resolveAuthHeader returns {header, keys} instead of a string. Both were needed to trace a credential back to its file.
  • Known gaps, deliberately not addressed here: an uppercase NPM_CONFIG_* variable is reported under its lowercase alias; attribution is computed pre-request, so a cross-host redirect that strips the auth header would still be reported as a rejection; the lockfile arm of URL attribution is untested. redactUrl's non-URL fallback can leak the tail of a password containing a literal @ — pre-existing and untouched.
  • Still to come on this branch: pnpm scope-qualified auth keys (//host/:@scope:_authToken, written by pnpm login --scope), and trailing-slash tolerance in the nerf-dart walk.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LKwAcpVJCkJgK3yfxfSVqu

sorccu and others added 4 commits August 25, 2026 01:34
…ED-891]

pnpm 11 stopped writing registry credentials to .npmrc: `pnpm login` writes
them to auth.ini in pnpm's global config directory instead. Embedded package
downloads only merged .npmrc files, so a logged-in pnpm user looked
unauthenticated and embedding a private package failed with HTTP 404 — npm
answers 404 rather than 401 for packages an unauthorized caller may not see.

Resolve pnpm's config directory the way pnpm does and merge auth.ini into the
configuration. The file is always consulted; only its precedence depends on
the project's lockfile: above the user .npmrc for pnpm projects, below it for
others, so each project follows its own package manager's model. Precedence
is what makes this work — the reported failure sent a stale .npmrc token, so
reading auth.ini without outranking that token would change nothing.

An unreadable auth.ini is skipped rather than fatal: unlike a .npmrc, it
belongs to another tool and must not take the whole command down. Expressing
that required defaultNpmrcPaths to return records instead of paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKwAcpVJCkJgK3yfxfSVqu
…ad fails [RED-891]

The credential hint fired only on 401/403, but registries routinely hide
packages an unauthorized caller may not see behind a 404 — npm does. So the
most common authentication failure produced a bare "HTTP 404" that reads as
"this package does not exist", sending people to look in the wrong place
entirely.

Extend the hint to 404, hedged, since a 404 can equally mean the package is
genuinely absent. Then say which credential was used and where it came from:
credentials can now arrive from a project .npmrc, the workspace .npmrc,
pnpm's auth.ini, the user .npmrc, or an npm_config_* environment variable,
so "your credentials were rejected" without naming the source leaves the
reader as stuck as the bare status code did.

Tracking that source required loadNpmrcConfig to report which key each value
came from, and resolveAuthHeader to report the keys it matched. Both halves
of a username/_password pair are reported, because precedence is per key and
the halves routinely live in different files — naming only the username
would point at the half that cannot expire.

Credentials embedded in a registry URL are attributed to the URL and to
whatever configured it: axios sends those itself and drops the Authorization
header when it does, so reporting the config entry would name a credential
that never reached the wire.

Config file paths and key names appear in these messages; credential values
never do, and the tests assert that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKwAcpVJCkJgK3yfxfSVqu
Download failure messages ran unparseable URLs through a regex to strip
userinfo before printing them. Five review rounds found five ways past it:
it stopped at the first `@` so a password containing one kept its tail; a
widened form matched an empty userinfo and ate a path separator; a
host-less string parsed as an opaque scheme so the parser left the
credential untouched; anchoring the pattern let an authority at a non-zero
offset through; and a query string carrying a pre-signed signature was
never considered at all.

Telling userinfo from a path in a malformed string needs a parser, so stop
trying. A URL is now rebuilt from scheme, host and path — userinfo, query
and fragment are gone by construction rather than stripped — and anything
that does not parse, or parses without a host, is withheld entirely. This
is the rule `rest/errors.ts` already applies to proxy URLs.

Nothing is lost by withholding it: the invalid-URL error now names the
config key and file that produced the value, or the lockfile that recorded
it, which is where the reader goes to fix it anyway.

Also here, from the same review rounds:

- Config origins are structured rather than a sentinel string, so an
  environment variable is named by its verbatim spelling. NPM_CONFIG_REGISTRY
  was being reported as npm_config_registry, a name that exists nowhere.
- A redirect is reported for any failing status, and the credentials are
  only called rejected when the answering host actually received them.
  follow-redirects drops the Authorization header across hosts, so blaming
  a credential the CDN never saw sent readers to rotate a working token.
- The message vocabulary moves to diagnostics.ts with a colocated spec.
  Direct unit tests there would have caught all five redaction leaks; they
  were previously reachable only through the HTTP sandbox harness.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKwAcpVJCkJgK3yfxfSVqu
…891]

Rebuilding a URL from scheme, host and path still kept the one
credential-bearing component the proxy-URL precedent in rest/errors.ts
drops. Some registries take a token as a path segment
(https://host/<token>/npm/), so the path is not safe to echo either.

Only scheme and host survive now. The path was the component least worth
keeping: every message that shows a URL already names the package and
version separately, which is what the path encodes.

Also correct the invalid-URL message, whose advice did not match its
branch. A tarball URL recorded in the lockfile was answered with "a
registry must be an absolute URL", pointing the reader at a registry
setting that is not involved and is probably already correct. Each branch
now describes the source it actually came from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKwAcpVJCkJgK3yfxfSVqu
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