Skip to content

cafile relative paths resolve against process cwd, not the .npmrc directory #11624

@zkochan

Description

@zkochan

Summary

cafile=<relative-path> in .npmrc is read via loadCAFile:

function loadCAFile (cafilePath: string): string[] | undefined {
  let contents: string | undefined
  try {
    contents = fs.readFileSync(cafilePath, 'utf8')
  } catch { /* ... */ }
  // ...
}

fs.readFileSync with a relative path resolves against process.cwd(). pnpm's CLI doesn't process.chdir(opts.dir) when --dir is passed, so a project .npmrc containing a relative cafile= is read from the wrong directory when the user runs pnpm from somewhere other than the project root.

Reproducer

mkdir -p /tmp/project
echo "cafile=certs/ca.pem" > /tmp/project/.npmrc
mkdir /tmp/project/certs
# (any valid PEM)
cp /etc/ssl/cert.pem /tmp/project/certs/ca.pem

cd /home/user                 # NOT /tmp/project
pnpm --dir /tmp/project install

Expected: pnpm loads /tmp/project/certs/ca.pem (resolved against the directory of the .npmrc that contains the cafile= key).

Actual: pnpm reads /home/user/certs/ca.pem, fails open via the try { ... } catch {} swallow, and the install proceeds without the custom CA. The user gets either a successful install (when the system CA bundle happens to cover the registry) or a TLS error that doesn't mention the missing cafile.

Why it bites

  • The user-facing intent of "put a cafile in the project's .npmrc" is "this CA applies to this project". Resolving the path relative to whatever cwd happens to be active when pnpm runs breaks that intent the moment --dir is used (CI scripts, monorepo wrappers, npx-style invocations, etc.).
  • The failure is silent. The try/catch in loadCAFile drops the CA list to undefined, the install continues, and the user only notices when TLS errors surface against a private registry that needed the missing CA. There's no warning, no telemetry, no log line tying the error back to the misresolved path.
  • Other path-valued .npmrc keys (notably cert=, key=) have the same shape and likely the same bug — loadNpmrcFiles.ts only does path.resolve for ${VAR}-substituted values, never for plain relative paths.

Proposed fix

loadCAFile (and the analogous read sites for cert= / key=) should resolve relative paths against the directory the .npmrc was read from, not process.cwd(). Concretely:

// inside loadNpmrcFiles, where the .npmrc dir is already known:
const cafilePath = path.isAbsolute(rawCafilePath)
  ? rawCafilePath
  : path.resolve(npmrcDir, rawCafilePath)

npmrcDir is already available at the call site — loadNpmrcFiles walks the cwd / home / global hierarchy and knows which directory each .npmrc came from.

The fix is backwards-compatible for absolute paths (the dominant production case — most CI templates use absolute paths). It changes behavior only for the relative-path case that's broken under --dir today.

Compatibility considerations

  • Users currently working around the bug by passing an absolute path see no change.
  • Users who happen to cd into the project before running pnpm see no change (cwd === npmrcDir, so the resolution is identical).
  • Users running pnpm --dir /other install from elsewhere with a relative cafile= see a correctly resolved path where they currently see a silent failure.

The only behavior change is "the install now finds the CA file the user obviously wanted" — strictly an improvement.

Related downstream context

pacquet (a Rust port of pnpm) matches this behavior bug-for-bug per its own cardinal rule of "do not 'fix' pnpm quirks unless the same fix has landed upstream." Once a fix lands here, pacquet will port it. Reference: pnpm/pacquet#498.


Written by an agent (Claude Code, claude-opus-4-7).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions