Repository navigation
fix(parser): resolve npm identity via npm's own parser, refuse otherwise (#195) - #199
Merged
Merged
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
Three of four seats found blocking defects; two independently found the same one. R1 tri-state: rev 1 returned str|None and fell back to the tables on ANY None, which contradicts its own 'drift refuses' rule -- a refusal would hit the table scan and remint the collision. Now Identity/REFUSED/UNAVAILABLE, and only UNAVAILABLE falls back. R2 mapping: rev 1 left remain+package -> identity unspecified. A naive skip-first-token makes 'npm run mcp' resolve to 'mcp', which update_server then installs and executes -- reopening #183, closed hours earlier. Mapping is now specified and pinned. R3 test paths: rev 1 moved the existing #182/#183 regression set onto the fallback fixture, so it would have stopped covering production on any node-ful host. Now parametrized over both paths. Also: 'exact by construction' was false (npm_config_package splits identity from argv); the npx-cli.js SHA pin never fires (byte-identical 10.8.2 -> 11.19.0) and guarded the wrong component, replaced by an npm-major ceiling; per-request try/catch is mandatory (--__proto__ throws inside nopt); the idle reaper plus sticky-unavailable would have killed the feature after one idle period; and the drift criterion was vacuous. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
Operator chose to stop modelling npm and refuse instead. Each board round on rev 1/rev 2 found a new way a CONFIDENT answer is wrong, and the surface kept growing: workspace bin resolution, .npmrc at three levels, per-server cwd, and libnpmexec differing WITHIN npm 11.x while npx-cli.js stays byte-identical. Measured basis for narrowing: 78 of the manifest's 79 npm servers use the plain 'npx -y <pkg>' shape. None sets cwd, npm_config_*, --call, multiple --package, or workspace flags. Refusal costs nothing on the shipped fleet. Blocking findings folded in: - remain[0] after a faithful npx pre-scan is 'exec', and 'exec' is a real published package -- rev 2 would have probed 'npx -y exec@latest --help' for all 79 npx servers. - An invalid spec becomes the package 'undefined', which also exists on the registry; specs now validate through npm-package-arg. - --call was unreachable behind the --package rule. - dlx is not an npm subcommand. - env/cwd are now REQUIRED parameters through detect_package_type and all six call sites; rev 2 declared env an identity input but no production signature carried it. - Drift detection is behavioural (a spawn-time invariant corpus) rather than a version ceiling, which was proven to guard at the wrong granularity. - AC3 rewritten to a call counter on the table-scan entry point: a seat wrote a faithful-but-wrong implementation that passed rev 2's wording while the scan demonstrably ran. User and global .npmrc remain unguarded and are named as the one known open hole rather than left to be discovered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
Rev 3 boarded with six blocking findings, four verified confident-wrong answers.
Root cause was structural: the gate was a denylist of 'unusual', so every round
found another unusual thing. Every npm_config_* option is equally settable as a
FLAG, and rev 3's denylist covered only env -- verified: --userconfig, --prefix
and --registry each redirect resolution while leaving nopt's remain pristine.
Rev 4 flips it: refuse unless every parsed nopt config key is in {yes, package}.
nopt hands back the parsed keys, so this is nearly free and closes keys npm has
not invented yet. 78 of 79 shipped servers parse to {yes} alone.
Other verified findings folded in:
- An alias spec (pkg@npm:real) has a VALID npa name but runs a different
package, so rev 3's 'no valid name -> refuse' passed it. Now requires npa type
in {tag, version, range}.
- The effective cwd, not the server cwd, is what npm resolves from; rev 3's
non-goals wrongly claimed the project .npmrc case was closed.
- Server env setting PATH selects WHICH npm runs, and the two npm trees on this
host genuinely disagree.
- Refusal is safe, but not for the reason rev 3 gave: the coarse fallback
identity DOES collide; safety comes from the 'unknown' type failing every
comparison closed. Real cost stated: refused servers regenerate every cycle.
- The call-site list was a closure, not six sites: get_package_version and
_detect_effective_version_pin both grow, plus ~10 test fakes mypy never sees.
- AC3 was again satisfiable by a broken implementation -- rev 3 added a counter
and dropped the result assertion. Now asserts the result.
- AC1 was satisfiable by resolving almost nothing; now names required forms.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
…ck (#195) Late CLI seats on rev 3 returned two DISAGREE with findings rev 4 missed. Worst: a failed spawn-time self-test was specified to report UNAVAILABLE, which means fall back to the tables. That is a fail-open in exactly the situation that proves the host parser is not modelled correctly -- responding by consulting the known-incomplete 2.5.2 tables is the worst available choice. Self-test failure and hash mismatch now REFUSE for the process lifetime; UNAVAILABLE is reserved for spawn failure, where we learned nothing about npm. Two of the plan's own verification commands proved nothing, both verified: - PATH=/usr/bin:/bin still contains node, npm and npx on this host, and excludes uv, so the 'node-less' run was neither node-less nor runnable. - The wheel smoke ended in Python's '...' no-op, so it imported the lazy resolver and never loaded the packaged .js it exists to check. AC3 was unsatisfiable: it demanded the tables return a non-None wrong answer for EVERY gate, but they already return ('unknown', None) for two distinct --package. Now scoped to the gates where they genuinely mislead. AC1 fought its own gate: the corpus observes npm via npm_config_registry, which the gate refuses, so feeding that env to resolve() refuses everything. The dead registry now goes to the binary only, and AC1 additionally requires a case where the tables are wrong and the resolver matches -- otherwise the 79 plain servers go green on tables alone. Also: a ready handshake before the first query (the self-test shares stdout, so the first resolve could read a self-test line as its answer); createRequire from the resolved npx-cli.js; the per-request catch must wrap the pre-scan too; npx switches must keep npx-cli's hardcoded extras; a hashable, explicitly-defined memo key; ServerConfig has no cwd (LocalMcpServerConfig does); and get_package_version's independent caller at handlers.py:5242. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
Rev 5 boarded 3 DISAGREE. Its gate, read literally, would have refused every npm server on every host -- twice over: - The env gate said 'effective env (sanitized_subprocess_env overlay)', but that function is os.environ.copy() plus the overlay, and every Unix process has PATH. It now reads the OVERLAY dicts (extra_env / config.env). HOME added: HOME=/x with /x/.npmrc package=other redirects resolution. - The cwd gate named package.json and .npmrc. Missing node_modules left the local-bin case open, and walking for .npmrc matches ~/.npmrc for any cwd under /home/viperjuice. Now gates on npm's own localPrefix rule, read from its source: hasPackageJson || dirExists(p, 'node_modules'). The memo could not capture mutable state -- creating .npmrc or node_modules after caching, or upgrading npm, changes resolution without changing the key, and a hit skipped the version re-stat. Entries are now stamped and revalidated. The differential harness was unsafe: a dead registry stops fetching but not executing, and libnpmexec returns into execution before any fetch, so running 79 manifest names would have launched installed server code. Now hermetic. AC6 still said a failed self-test 'degrades to the tables' -- the same fail-open rev 5 removed from the design but left in the acceptance contract. Per operator decision: this goes to implementation without a sixth plan-board round. Rounds 1-3 found invisible wrong-identity defects, which need a board; round 5's are refuse-everything defects that fail loudly on the first test run. The DIFF gets boarded instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
…oracle (#195) Ports the npx-cli.js pre-scan verbatim, loads the host npm's nopt, config definitions and npm-package-arg through a createRequire rooted at the resolved npx-cli.js, and answers one question over NDJSON: can this server's package be named with certainty? Everything else refuses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
… child (#195) IDENTITY / REFUSED / UNAVAILABLE, where only UNAVAILABLE (spawn failed: no node, no npm root) may fall back to the flag tables. Parent-side gates (server env overlay, gateway process env, effective-cwd local prefix) need no child. Memo is dropped with the child so the drift re-stat stays live; respawn budget is time-based so a long-running gateway is never stranded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
detect_package_type, _npm_package_arg, get_package_version and _detect_effective_version_pin all grow required env/cwd parameters, and every production call site is converted. Undefaulted on purpose: a defaulted None is the same silent fail-open this change exists to remove, and requiring them turns an unconverted caller into a type error rather than a wrong answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
Every existing npm identity test now runs under BOTH production paths -- resolver-active and resolver-unavailable -- rather than being moved onto the fallback fixture, which would have stopped covering production on every node-ful host. Where the two answers differ the test spells out both; the resolver refuses in every such case but one (npx --yes null zz), where it matches the real binary and the tables over-refuse. Also converts the ~17 get_package_version fakes that mypy cannot see. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
…#195) AC3 is parametrized over EVERY refusal gate for which the 2.5.2 tables return a non-None wrong answer, asserted on the result, with the table scan counter instrumented inside the scan itself rather than monkeypatched. Self-test failure, hash mismatch and parser-load failure are each proven end-to-end against a fixture npm root. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
pyproject names the helper explicitly so a future include-rule change cannot drop it silently, and install-smoke now RESOLVES a package through the installed helper rather than importing a module and asserting nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
A timeout, death, protocol violation or STALE tears the child down and returns REFUSED for that one request. Memoising it made one transient stall permanently disable identity for that argv -- a gateway re-queries the same fixed server set every cycle, so the entry would be re-served for the process lifetime even after a healthy respawn. Also returns a sticky refusal before the memo lookup rather than after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
Resolver vs flag tables vs the REAL npm binary, over every historical form, every gate, the 79 manifest npm servers and a randomly-seeded fuzz component. Pass condition is zero unsafe rows -- resolver matches the binary or refuses, and a no-fetch/crash row must refuse -- plus at least one case where the tables are wrong and the resolver is right. Hermetic per invocation: empty temp cwd, temp HOME/prefix/cache, dead registry, process-group kill. The temp base is checked for an npm local prefix and the run aborts if none is clean, because a dead registry stops npm fetching but NOT executing a local or global bin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
…195) States the refusal gates plainly, that refusal reduces auto-update coverage rather than guessing, the node-less fallback, and the one known residual (user/global .npmrc). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
) A sticky refusal disables auto-update for every npm server behind a single WARNING, which is easy to miss in a gateway that runs for days. status_summary() never spawns -- it reports only what is already known, so a diagnostic call cannot start a subprocess or report a healthy resolver on a gateway that has never resolved anything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
…ment (#195) A resolver that refuses for its process lifetime keeps no child, so nothing re-checks the host and it does not recover on its own. The WARNING now says so and points at gateway.health, and tests/conftest.py documents the tmp_path trap: a test that passes only because npm identity refused is a false green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
…tity # Conflicts: # CHANGELOG.md # tests/test_version_checker.py
CI caught a genuine cross-major divergence this host could not see.
Measured against both binaries with a dead registry:
npm 10.9.4 npm exec -- --flag-thing fetches /--flag-thing
npm 11.19.0 the same argv fetches /undefined
npa 12 reports {range, '--flag-thing'} where npa 13 reports {tag,
undefined}. Two installed npms, two different packages, one command
line -- so this is exactly an identity that cannot be stated with
certainty, and the previous code minted '--flag-thing' on npm 10.
Refusing before npa sees the token also makes the spawn-time self-test
an INVARIANT rather than a description of whichever npm the author had:
without it the self-test passed on npm 11 and FAILED on npm 10, which
disabled the whole resolver on every npm 10 host.
Verified: the child's answers over 248 corpus cases are byte-identical
between npm 10.9.4 and npm 11.19.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
1. --package no longer short-circuits the npm subcommand allowlist. #183 was fully reopened: `npm run/start/test/create/dlx/rum --package=X` and bare `npm --package=X` all minted ('npm','X'), and handlers.py would have fetched and executed X@latest off the public registry for a command line that runs a package.json script. The subcommand check now runs first and unconditionally; npx, which has no subcommand, still resolves --package. 2. UNAVAILABLE is now reserved for CONFIRMED node absence. A missing packaged helper (broken install) and any non-FileNotFoundError OSError from Popen (permission, resource limits) used to route to UNAVAILABLE and therefore to the 2.5.2 tables; both were reproduced resolving `npx --registry https://private.invalid probe` to 'probe'. Both now REFUSE. 3. The corpus enforces AC1's positive half. 'Zero unsafe rows' judged every refusal safe, so a refuse-everything resolver passed. Every manifest row and every req-* row must now be IDENTITY and match the binary, with counts printed; verified by a negative control that now exits 1. 4. The memo is gone. It was dropped with the child and on a handshake version change, but both fire only at lifecycle boundaries -- a cache HIT never reached the child, so the per-resolve npm re-stat could not fire and an in-place npm upgrade mid-session would have served a stale identity indefinitely. Every query now re-stats; ~0.5 ms each. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
… locate (#195) Found while running the board's requested npm 10.8.2 verification: the GitHub Actions runner's bundled node20 ships <prefix>/bin/npx as a COPY of npx-cli.js rather than a symlink, so walking up from it never reaches the npm package. The resolver reported UNAVAILABLE and fell through to the flag tables -- the same fail-open as the missing-helper case. rootFromEntryPoint now also checks npm's own global layout (<prefix>/lib/node_modules/npm), against the ENTRY POINT'S OWN prefix only: checking it at every level of the walk made a stray npx in /tmp adopt this host's /lib/node_modules/npm. And when no root is found while an npx/npm entry point IS on PATH, the child now REFUSES rather than reporting UNAVAILABLE; only a host with neither on PATH is UNAVAILABLE. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
… kill the child (#195) MUST-FIX 5. npm reads the `package` config for exec/x ONLY, and the install family takes a LIST. Treating it like exec gave three wrong answers, all verified against the real binary: npm install pkg-real --package=pkg-other -> 'pkg-other' (npm fetches pkg-real) npm install --package -> 'true' (npm fetches nothing) npm install a b -> 'a' and npm install a c -> 'a' (one identity, #180) install/i/add now refuse a `package` key and refuse unless exactly one positional follows. exec/x are untouched: their trailing positionals are arguments to the binary, so `npm exec pkg-one pkg-two` and `npx -y pkg-one pkg-two` still resolve. MUST-FIX 6. `npx -y 'pkg@>=1.0 <2.0'` is a legal npa range and npm fetches `pkg`. The parent's spec-shape check rejected whitespace and TERMINATED a healthy child for it -- the id matched and the JSON parsed, so the stream was never desynchronized -- and the next server in the same refresh cycle got 'cooling down after a failure'. One such manifest entry disabled npm identity fleet-wide for the cooldown window, every cycle. Teardown is now reserved for a genuinely desynchronized stream (timeout, death, malformed JSON, id mismatch); a record the parent merely dislikes refuses that one query and leaves the child alive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
(a) The refresher's identity overlay now carries the credential
VARIABLE NAME. The old comment asserted a credential name 'cannot
match the gate's key patterns' -- false: env_var/secret_key are
manifest-controlled and 'npm_config_registry' passes
validate_env_var_name, so it would be injected at spawn and redirect
npm without the gate ever seeing it. Names only; values stay empty,
because the gate reads keys and must never read a secret.
(b) A PARENT-side spawn failure (permission, resource limit, missing
helper) is no longer sticky. Those are repairable under a running
gateway, and making them permanent turned a transient condition into
a fleet-wide loss of auto-update for the process lifetime. They
refuse and back off on the re-arming cooldown; only what the CHILD
reports about the host stays sticky.
(c) The one-per-process WARNING now describes the outcome it is about.
A sticky UNAVAILABLE still MINTS identities, from the flag tables --
the REFUSED wording ('servers will report package_type=unknown') was
simply false for it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
This was referenced Aug 27, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
See #195.
What this does
detect_package_type(command, args)derives a package identity from a server'slaunch command line, and the freshness gate reads a matching identity as
positive confirmation that a cached tool description still describes the
configured package. A wrong identity therefore serves one server another
server's tool descriptions.
That parser has been repaired five times in production (#180 → #192 → #194 →
#195 → the 2.5.2 nullable-boolean spelling). Every defect was in the rules
around the hand-written flag tables rather than a missing entry, and every one
produced a confident wrong answer. This replaces the guessing with npm's own
parser plus aggressive refusal.
For an
npx/npmserver, a persistent node child (src/pmcp/manifest/_npm_resolve.js)loads the host npm's own
nopt, its own@npmcli/configdefinitions and itsown
npm-package-arg— through acreateRequirerooted at the resolvedbin/npx-cli.js, so the modules always come from the same npm the pre-scan wasread from — and runs a verbatim port of
npx-cli.jslines 10–123, includingall three of its flag sets, with
switchesrecomputed from the host's livedefinitions. The identity is
remain[1], notremain[0]:npx-cli.js:7splicesexecinto argv, andexecis a real published package, so readingremain[0]would have made
gateway.update_serverprobenpx -y exec@latest --helpfor all79 npx servers in the manifest.
The refusal gates
An allowlist of plain shapes, not a denylist of dangerous ones — three
adversarial rounds on a denylist each found another way for a confident answer
to be wrong, because every
npm_config_*option is equally settable as acommand-line flag and npm keeps adding new ones.
--yes/--package. That includes--registry,--userconfig,--prefix,--cache,--call,--workspace, a shorthand like--silent(which expands to--loglevel), and unknown flags, which nopt reports as boolean keysnpm_config_*(case-insensitive),PATH,HOME,NODE_PATH,NODE_OPTIONS,PREFIX,NVM_*npm_config_*orNODE_OPTIONS— sticky for the processpackage.jsonornode_modulesin any ancestor), because a project.npmrccan rename the package and anode_modules/.binentry means npm never reaches the registrynpm run/start/test/create, a typo, barenpm -y pkg, andnpm dlx(pnpm/yarn spelling —npm dlx probeisUnknown command, yet it sat in the tables' subcommand allowlist)npm-package-argtypetag/version/range. Notably alias:npx -y myalias@npm:left-padreally runsleft-pad, and the corpus confirms it against the binary — the tables mintmyalias-zz, a squattable different package--package-. The npm majors disagree about what such a token names — see below — so it cannot be named with certainty--packagecannot short-circuit it.npm run/start/test/create/dlx/rum --package=Xand barenpm --package=Xall refusenpm install/i/addpackageconfig key is present (npm reads it forexec/xonly), or the operand list is not exactly one package.npm install a bandnpm install a care two configurations, not onenpx/npmentry point is onPATHbut its package root cannot be found. That is not absence, so it must not reach the tablesbin/npx-cli.jsis not one this port was verified against, or npm's parser will not loadTri-state, and only one state may consult the tables.
IDENTITYreturns thespec raw (
_strip_npm_tagstays indetect_package_type, becauseupdate_server's pin detection needs the suffix).REFUSEDreturnsNoneandnever scans the tables.
UNAVAILABLE— spawn failed, i.e. no node or nonpm root — falls through to the tables, which is the pre-2.5.2 behaviour and all
a node-less host can do.
A failed self-test refuses; it does not degrade to the tables. A failed
self-test is precisely the evidence that the tables' model of npm is wrong, so
consulting them there is fail-open.
Resolved an internal contradiction in the plan's Drift section. It said both
"a hash mismatch takes the same REFUSED path — there is no UNAVAILABLE-with-reason
state; that phrasing was rev 3's and is gone" and, two sentences later, "a
mismatch produces the same UNAVAILABLE-with-reason + WARNING". Implemented as
REFUSED-for-lifetime + one WARNING, matching the sentence that declares the other
phrasing gone and matching acceptance criterion 5.
Measured cost
Zero on the shipped manifest. All 79 npm-family servers use the plain
npx -y <pkg>shape; all 79 resolve, and all 79 match the package the realnpxbinary fetches.
The cost lands on unusual forms, and it is real — these are cases where the
resolver refuses and the binary would have resolved:
npx --pack zz binzznpx --frobnicate valpkg realbinvalpkgnpm exec --loglevel silly aaaaaaaaanpm exec --global true aaaaaaaaanpx --userconfig X probe-aprobe-aprobe-anpx --prefix X probe-aprobe-aprobe-anpx --cache X probe-aprobe-aprobe-anpx --package=p --call='echo hi'ppnpm create foocreate-foonpx -y myalias@npm:left-padleft-padmyalias-zz← wrongA refused server keeps running. It loses auto-update and version reporting, its
descriptions regenerate every refresh cycle, and
check_stalenesslists itpermanently stale. That is a cost, not a correctness problem — and it is why the
allowlist is drawn to cover the plain shape rather than as tightly as possible.
Runtime. 43 ms of one-time child startup (including the self-test corpus) and
~0.9 ms per query; 200 distinct resolves in 0.188 s with one spawn.
detect_package_typeis synchronous and called from inside async coroutines, soone-shot invocation (4.02 s for 79) was not an option.
Accepted residual
A
package=orregistry=line in a user or global.npmrcchanges what npmresolves and the resolver cannot see it. Project-level
.npmrcis covered by thelocal-prefix refusal, and
npm_config_*in the gateway's own environment is nowcovered by the process-environment check. The user/global rc file is the one
input that remains unguarded, and it is stated here rather than discovered later.
Operational note: a sticky refusal does not recover on its own — a
disabled resolver keeps no child, so nothing re-checks the host. It logs one
WARNING that now names the restart requirement, and
gateway.health→gateway_diagnostics.npm_identityreports the state on demand. Those two are theonly surfaces; without them a fleet-wide loss of auto-update would be invisible.
A transient child death is different: the respawn budget is time-based (60 s,
re-arming), so it cannot strand a long-running gateway.
Verification (measured, this branch)
The corpus now enforces AC1's positive half: every manifest row and every
req-*row must beIDENTITYand match the binary. Before, "zero unsafe rows"judged every refusal safe, so a refuse-everything resolver passed — verified by a
negative control that stubs the resolver to refuse everything and now exits 1.
Where the resolver beats the tables, confirmed against the binary:
npx --yes=maybe tok bin2maybetokmaybenpx --yes null aaanullnullnpx --no-install -y=false --userconfig 1 --pack(fuzz)falsefalseAcceptance criterion 6 — an unconverted call site must be a type error:
env/cwdare required and undefaulted ondetect_package_type,_npm_package_arg,get_package_versionand_detect_effective_version_pin; atest asserts they carry no default, so a habitual
= Nonecannot creep back.Deviations from the amendment of record
The coordinator's amendment (D1–D12) is reproduced verbatim below. Three
deviations, all measured:
npx --pack zz bin→
zznode-ful,("unknown", None)node-less. Measured: nopt reports anunknown flag as a boolean config key (
{pack: true}), which the step-1allowlist refuses — so it is
("unknown", None)on both paths. Substitutednpx --yes=maybe tok(yesis allowlisted, so it survives the gate):node-ful
maybe, node-lesstok, binarymaybe. The coordinator verifiedand accepted this.
removedSwitcheshas 3 entries, not 10).Immaterial — the port is verbatim from the real file, all three sets included.
Also accepted.
not found →
UNAVAILABLE(the plan names it); parser-load failure and apre-handshake protocol violation →
REFUSED-for-lifetime (npm is present andunmodellable, which is the self-test situation, not "we learned nothing").
Sticky recovery needs a gateway restart; the transient-death budget is
time-based.
Board findings on the diff, and what they changed
The diff was boarded twice. Six must-fixes, every one reproduced before it was
touched and re-measured after.
--packageshort-circuited the npm subcommand allowlist — #183 fully reopenednpm run --package=pkg-a -- bin→('npm','pkg-a'); same forstart/test/create/dlx/rum/barenpm('unknown', None);npx --package=X -- binstill resolvesUNAVAILABLEassigned too broadly, and it is the one state that may use the tablesPopenOSError, fell through to the tables and resolvednpx --registry https://private.invalid probe→('npm','probe')FileNotFoundErroronnodeisUNAVAILABLE--packageand discarded extra operandsnpm install pkg-real --package=pkg-other→pkg-other(npm fetchespkg-real);npm install --package→true(npm fetches nothing);npm install a bandnpm install a c→ botha, one identity for two configs('unknown', None);npm install a,npm exec pkg-one pkg-twoandnpx -y pkg-one pkg-twostill resolvenpx -y 'pkg@>=1.0 <2.0'→ REFUSED + teardown, and the next server in the same cycle got"cooling down after a failure"pkg@>=1.0 <2.0; teardown is now reserved for a genuinely desynchronized streamFinding 5 was caught by this harness's own randomized fuzz seed — which is why
that requirement existed. Three follow-ups were also closed: the refresher's
identity overlay now carries the credential variable name (an
env_varofnpm_config_registryis manifest-controlled, passesvalidate_env_var_name, andwould have redirected npm without the gate seeing it); a parent-side spawn
failure is no longer sticky, since permission and resource errors are repairable
under a running gateway; and the one-per-process WARNING no longer claims
identity is disabled in the
UNAVAILABLEcase, where the tables still mintidentities.
Separately, running the board's requested npm 10.8.2 verification surfaced a
layout the resolver could not handle: the GitHub Actions runner's bundled node20
ships
<prefix>/bin/npxas a copy ofnpx-cli.jsrather than a symlink, sowalking up from it never reached the npm package and the resolver fell back to
the tables. It now also checks npm's own global layout — against the entry
point's own prefix only, because checking every level of the walk made a stray
npxin a temp directory adopt this host's unrelated/lib/node_modules/npm.A cross-major divergence CI caught and this host could not
The first CI run failed, and the failure was correct. On npm 10.9.4 the
spawn-time self-test reported
npm exec -- --flag-thing: expected null, got "--flag-thing"and disabled the resolver. Measured against both realbinaries with a dead registry:
npm exec -- --flag-thingfetches/--flag-thing{type: 'range', name: '--flag-thing'}/undefined{type: 'tag', name: undefined}Two installed npms, two different packages, one command line. The original code
happened to refuse on npm 11 only because npa 13 returns no name — so it would
have minted
--flag-thingas a confident identity on every npm 10 host, theF2 defect the design set out to close, reopened through a version difference.
The fix refuses a package operand beginning with
-before npa is consulted.That is the only answer true on both majors, and it is what makes the
spawn-time self-test an invariant rather than a description of whichever npm
the author happened to have installed. Verified afterwards by running the
child's whole corpus — 248 cases, named + manifest + fuzz — under npm 10.9.4 and
npm 11.19.0 and diffing: 0 diverging.
This is worth stating plainly because the self-test is the mechanism that turns
a modelling error into a loud refusal rather than a quiet wrong answer, and it
did exactly that on its first exposure to an npm this machine does not have.
An environment finding worth recording
This development host has
/tmp/package.jsonand/tmp/node_modules/.bincontaining real executables. Two consequences, both verified:
libnpmexecruns amatching local or global bin and returns into execution before any fetch. A
differential harness rooted in
/tmpwould therefore have launched installedbinaries.
differential_npm_corpus.pyrefuses to run unless it finds a tempbase with no npm local prefix, and aborts loudly naming every candidate it
tried rather than silently relocating.
tmp_pathunder/tmp, so anymonkeypatch.chdir(tmp_path)testsilently flips from resolving to refusing. That is nasty in one direction only:
a test asserting a refusal then passes for the wrong reason and looks green
forever.
tests/conftest.pynow documents this at the top of the file, and theone affected test class pins its identity path explicitly.
Pre-existing failure, not from this change
tests/runtime's stdio-harness tests (test_downstream_stdio.pyandTestStdioEmitterReachesDispatch) hang on this host. Proven not to be thischange:
PATH, where the resolver spawns no childat all and identity is byte-for-byte 2.5.2;
ea8333c, run from a cleangit archiveexport withPYTHONPATHpointed at it (exit=124for both);The remaining 52 runtime tests pass. This wants its own investigation.
Amendment of record (coordinator, quoted verbatim)
D1 — NEW HOLE, design-side. Gate the gateway's OWN process environment too.
env npm_config_package=evil-pkg-abcz npm_config_registry=http://127.0.0.1:9 npx -y probe-abczfetcheshttp://127.0.0.1:9/evil-pkg-abcz. Add a one-time startup check ofos.environfor anynpm_config_*(case-insensitive) orNODE_OPTIONS; if present, the resolver REFUSES for its lifetime and logs exactly one WARNING naming the offending key.D2 — AC3 must be parametrized, not a three-item list. Required for every step-1/step-4 refusal path for which the 2.5.2 tables return non-
None, keeping the two-distinct---packagecarve-out.D3 — AC2 has no pass condition. Add: zero unsafe rows — every recorded verdict must show
resolver == binaryORresolver == REFUSED; rows where the binary made no fetch or crashed require REFUSED.D4 — Randomize the fuzz seed per run and print it.
D5 — The memo defeats the drift defence. Include the child generation / handshake
npmVersionin the memo key, or flush the memo on every respawn and npm-version change. Also specify: (a) does a REFUSED-for-lifetime resolver still re-stat, or does recovery need a gateway restart? (b) respawn-once with no re-arm strands a days-running gateway after two transient child deaths — make the budget time-based or re-armed.D6 — The npx-cli port spec is wrong about ranges and sets. The loop is lines 66–124, and
removedSwitches,switchesandoptsall live outside the cited 63–124. Read the real file and enumerate all three sets.D7 —
parsed.packageis a LIST. "A single--packagevalue" meanslen(...) == 1, not truthiness.--package=""must be tested by key presence, not JS truthiness.D8 — AC4 mixes return conventions. Name the function per clause.
D9 — AC7 cannot detect a resolver that ignores PATH removal. Add one discriminating form:
npx --pack zz binmust bezznode-ful and("unknown", None)node-less.D10 — Two failure modes are unclassified: a pre-handshake protocol violation, and
loadParser()failing to require. Classify each as REFUSED-lifetime or UNAVAILABLE and say which.D11 — The hung-child criterion is vacuous where it runs. Split into two criteria on two hosts.
D12 — Factual fix: the manifest is 79/79 plain shape, not 78/79.
Also worth surfacing: a fleet-wide auto-update disable behind a single WARNING is easy to miss — if
gateway.healthhas a natural place to report resolver state, add it.🤖 Generated with Claude Code
https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3