Skip to content

feat(cli): read pnpm's auth.ini for embedded package credentials [RED-891] [ship] - #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] [ship]#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.

Say where the credentials came from

The hint now also fires on 404 (hedged — a 404 can equally mean the package is absent) and names the source. With credentials arriving from any of five places, "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
'https://registry.npmjs.org' (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/u/.npmrc'.

and when nothing matched, every place that was consulted:

… (HTTP 404). No credentials for this registry were found in 'npm_config_*
environment variables', '/repo/.npmrc', '/Users/u/.npmrc'. A registry may
answer 404 for a package you are not authorized to see, so the package may
exist but be invisible without credentials.

Both halves of a username/_password pair are named, since precedence is per key and the halves routinely live in different files — naming only the username points at the half that cannot expire. An environment variable is named by its verbatim spelling (NPM_CONFIG_REGISTRY, not the case-folded key). The same hint now also covers the registry-metadata request that yarn plans make, which authenticates identically.

Redirects. follow-redirects strips Authorization across hosts, so a tarball download that redirects to a CDN is answered by a host that never saw the credentials. Calling them rejected there would send someone to rotate a working token, so the hop is reported instead. Whether the header survived is observed from the post-strip options in beforeRedirect rather than re-derived from the library's policy — subdomain redirects keep it, protocol downgrades drop it regardless of host.

Never echo a URL that cannot be shown safely

A URL in an error is now rebuilt from scheme and host only. Userinfo, path, query and fragment are absent by construction rather than stripped: a registry URL may embed a token, a pre-signed CDN URL puts its signature in the query, and some registries take a token as a path segment. Anything that does not parse, or parses without a host, is withheld entirely — the rule rest/errors.ts already applies to proxy URLs.

This replaced a regex that tried to strip userinfo from malformed strings. Review found five ways past it (truncating at the first @, matching an empty userinfo, a host-less parse that left the credential in the path, an authority not at offset 0, and a query never considered), so the approach went rather than its fifth patch. Nothing is lost: 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.

Reviewer notes

  • loadNpmrcConfig returns {config, files, 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 source.
  • The message vocabulary lives in diagnostics.ts with a colocated spec. It is worth keeping it directly unit-tested: every one of the redaction leaks above was found by review rather than by the end-to-end tests that were the only coverage at the time.
  • registryHttpError (added by feat(cli): prune the bundled lockfile to the code bundle's contents [RED-886] [ship] #1442) gained a hint callback so the download and metadata paths share one wrapper without sharing one fixed hint.
  • 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. Neither is started; resolveAuthHeader currently probes only slash-terminated darts and ignores the scope.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LKwAcpVJCkJgK3yfxfSVqu

sorccu and others added 4 commits August 25, 2026 05:19
…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
@sorccu
sorccu force-pushed the simo/red-891-pnpm-auth-ini-support branch from e51195a to b9ea0e1 Compare August 24, 2026 20:26
@sorccu sorccu changed the title feat(cli): read pnpm's auth.ini for embedded package credentials [RED-891] feat(cli): read pnpm's auth.ini for embedded package credentials [RED-891] [ship] Aug 24, 2026

@github-actions github-actions 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.

Auto-approved: ship/show PR from a same-repo branch.

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