Skip to content

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

Merged
sorccu merged 5 commits into
mainfrom
simo/red-891-pnpm-auth-ini-support
Aug 25, 2026
Merged

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

Honor pnpm scope-qualified auth keys

pnpm login --scope=@acme (pnpm 11.7+) writes a scope-qualified key — //host/:@acme:_authToken — instead of a plain nerf dart. resolveAuthHeader now takes the package name, derives its scope, and mirrors pnpm's lookup order: every scoped nerf dart is tried before any unscoped one (a shallow scoped credential outranks a deeper unscoped one, not interleaved by depth, matching pnpm's getAuthHeaderByURI). An unscoped package never falls back to a scoped key — a scoped token belongs to one organisation by construction.

pnpm's alternative path-form spelling (//host/@acme/:_authToken) is honored too, but ranked after the unscoped walk rather than at pnpm's rank: that spelling is indistinguishable from an ordinary nerf dart for the path /@acme/ — which is exactly how npm, yarn and bun read it — so giving it pnpm's rank would let it outrank a deeper unscoped key that authenticates a working setup today. Ranked last it is purely additive.

Scoped keys are honored in any project, whichever package manager it uses: writing one is an unambiguous statement of which token that scope should use, and ignoring it would leave a pnpm login --scope-only user unauthenticated for exactly the packages the key names.

Blank values are not absent values

A credential entry left blank (by a token rotation, or an edit) counts as no credential for its own kind — npm and pnpm both test credential values for truthiness — but still masks the same key everywhere else: falling through to another spelling or another file would send a credential npm would not. A blank value and an unset ${VAR} stay distinct: the former is an entry holding nothing, the latter a reference to something that does not exist, which fails the download and names the variable.

A blank registry is a broken setting rather than an absent one: falling back to the public registry would disclose a private package's name to it, so the download fails and names the key. A blank @scope:registry does fall back to a usable global registry, matching npm and pnpm.

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. A tarball URL that came out of the registry's package metadata is attributed to that registry — telling someone to fix their lockfile or registry setting for a URL the registry served sends them to a setting that is already correct.

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.

The rule is now centralized in url.ts: a URL is echoable exactly when it is fetchable (parseFetchableUrl — absolute http(s) with a host), and a registry URL a path will be composed onto must additionally carry no query or fragment (parseComposableUrl — with registry=https://, the composed URL parses with the package name as its host, and the request, with any credential nerf-darted to it, would go to whoever owns that name). ResolvedRegistry became a discriminated union so an unusable registry cannot be composed onto by accident.

Other changes

  • A rebase of this branch accidentally committed git conflict markers into src/ai-context/references/configure-playwright-checks.md — a file no test parses and the AI-context CI check never reads. This PR repairs the file and adds src/ai-context/__tests__/sources.spec.ts, which walks every shipped file under src/ai-context and fails on conflict markers.

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.
  • pnpm's tokenHelper is deliberately unsupported: it names an external command to run for a token, and running a command found in a config file is a decision well beyond resolving a credential.
  • Still to come in a follow-up: trailing-slash tolerance in the nerf-dart walk (a hand-written //host/path:_authToken without the trailing slash is currently invisible; npm probes both forms, pnpm normalises at load), plus a set of smaller diagnostics refinements.

🤖 Generated with Claude Code

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[bot]
github-actions Bot previously approved these changes 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.

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

sorccu and others added 5 commits August 25, 2026 16:58
…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
… [RED-891]

resolveAuthHeader now derives the package's scope and mirrors pnpm's
lookup order: every scoped nerf dart (//host/:@acme) is tried before any
unscoped one, and pnpm's path-form spelling (//host/@acme/) is honored
after the unscoped walk, where it is purely additive.

Along the way, credential and registry resolution learn what a blank
value means (absent for its own kind, but still masking the same key
elsewhere), URL echo-ability and fetchability become one centralized
rule (url.ts), an unusable registry becomes unrepresentable as a
composition base (ResolvedRegistry is a discriminated union), and
failure messages attribute a tarball URL to the lockfile, the registry
metadata, or the composed registry that produced it.

Also repairs git conflict markers that a bad rebase committed into
ai-context/references/configure-playwright-checks.md — a file no test
parses — and adds a guard test that scans every shipped ai-context file
for conflict markers.

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 5e7b7f0 to e06c2bf Compare August 25, 2026 08:00
@sorccu
sorccu merged commit 51fc81b into main Aug 25, 2026
16 checks passed
@sorccu
sorccu deleted the simo/red-891-pnpm-auth-ini-support branch August 25, 2026 08:04
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