Repository navigation
Description
[agent] Found by the scheduled npm bug-hunt routine (ledger #302).
Summary
#1792 (fixing #1791) makes npm_bin::form_for_write treat a file as an npm-installed bin whenever the package's own package.json lists it (is_listed_bin), whatever is on disk, and then writes fixBin's LF-shebang form. But npm only runs fixBin on bins it actually links. In two common-enough shapes it doesn't link them, so the installed file keeps the published #!…\r\n shebang:
bin-links=false(npm install --no-bin-links, orbin-links=falsein.npmrc; used on filesystems without symlinks). There's nonode_modules/.binat all, and the bin file is installed byte-for-byte as published.- A package that declares its bins only through
directories.bin, installed from a non-registry spec (file:tarball here). npm 10 doesn't expanddirectories.binfrom the tarball'spackage.json(arborist reads it withread-package-json-fast), so it doesn't link orfixBinthose files, butlists_bincounts them as bins.
On main in both shapes:
applywrites the patched file with an LF shebang. That's neither the recordedafterHashbytes nor what npm would install for the patched package.rollbackthen writesfixBin(beforeHash), an LF shebang. The file differs from whatnpm installwrote, so rollback is no longer byte-exact. Onb4b6d98(before Fix CRLF npm bins left broken by v5.0.1 (#1791) #1792) and v5.0.1, both shapes roll back byte-exact.
apply --check says in sync, a re-apply says already patched, and vex attests, so nothing reports the mismatch.
Impact
Low functionally: an LF shebang still runs. But it breaks the rollback guarantee (CLI_CONTRACT.md rollback: "restore original files"). The file left behind after rollback matches neither the published tarball nor a fresh npm install, and the patched file isn't the patch's afterHash content. Projects that set bin-links=false get modified bin bytes on every apply/rollback cycle, which shows up as a diff wherever node_modules is checked in or hashed.
Repro
$SP = a main build (ecd4924). This runs both shapes:
#!/usr/bin/env bash
# usage: repro.sh '<pkg package.json>' <file in pkg> <extra npm install flags>
set -u
W=$(mktemp -d); cd "$W"
mkdir -p pkg/$(dirname $2) && printf '%s' "$1" > pkg/package.json
printf '#!/usr/bin/env node\r\nconsole.log("hi");\r\n' > pkg/$2
(cd pkg && npm pack --silent >/dev/null) && mv pkg/crlfbin-1.0.0.tgz .
mkdir proj && cd proj
echo '{"name":"p","version":"1.0.0","dependencies":{"crlfbin":"file:../crlfbin-1.0.0.tgz"}}' > package.json
npm install --no-audit --no-fund $3 >/dev/null 2>&1
cp node_modules/crlfbin/$2 ../installed.js # CRLF shebang kept by npm
gsha() { python3 -c 'import hashlib,sys;b=open(sys.argv[1],"rb").read();print(hashlib.sha256(b"blob %d\0"%len(b)+b).hexdigest())' "$1"; }
cp ../pkg/$2 before.js; cp before.js after.js; printf '// SOCKET-PATCHED\r\n' >> after.js
B=$(gsha before.js); A=$(gsha after.js)
mkdir -p .socket/blobs && cp before.js .socket/blobs/$B && cp after.js .socket/blobs/$A && rm before.js after.js
cat > .socket/manifest.json <<JSON
{"patches":{"pkg:npm/crlfbin@1.0.0":{"uuid":"e2f99e5d-3afd-5cbd-be80-6a0102bcd6c2","exportedAt":"2026-01-01T00:00:00Z",
"files":{"package/$2":{"beforeHash":"$B","afterHash":"$A"}},
"vulnerabilities":{"GHSA-xxxx-yyyy-zzzz":{"cves":["CVE-2099-0001"],"summary":"t","severity":"high","description":"d"}},
"description":"d","license":"MIT","tier":"free"}}}
JSON
$SP apply --offline | grep Summary; head -c 22 node_modules/crlfbin/$2 | od -c | sed -n 2p
$SP apply --check --offline | tail -1
$SP rollback --offline >/dev/null
cmp node_modules/crlfbin/$2 ../installed.js && echo ROLLBACK_BYTE_EXACTrepro.sh '{"name":"crlfbin","version":"1.0.0","bin":{"crlfbin":"cli.js"}}' cli.js --no-bin-links
repro.sh '{"name":"crlfbin","version":"1.0.0","directories":{"bin":"bin"}}' bin/cli.js ""Output on main ecd4924, npm 10.9.4 / Node 22.22.0, both shapes (each reproduced twice):
Summary: 1 of 1 targeted patch applied, 0 already patched, 0 not found on disk
0000020 o d e \n c o <- LF shebang written (recorded afterHash has \r\n)
Patches are in sync (1 patch checked).
node_modules/crlfbin/cli.js ../installed.js differ: char 20, line 1 <- rollback not byte-exact
The same script with a b4b6d98 build or v5.0.1 gives o d e \r \n c after apply and ROLLBACK_BYTE_EXACT.
Expected vs actual
- Expected: when npm didn't link the bin and so didn't rewrite its shebang,
applywrites the recordedafterHashbytes androllbackrestores the installed bytes exactly, as onb4b6d98. CLI_CONTRACT.mdrollback: "restore original files". - Actual:
applywritesfixBin(after)androllbackwritesfixBin(before), becauseis_listed_binshort-circuits the on-disk evidence.
OS × version
| OS | npm | --no-bin-links |
directories.bin-only file: tarball |
|---|---|---|---|
| Linux | 6.14.18 | fail | not run |
| Linux | 10.9.4 | fail | fail |
| Linux | 12.2.1 | fail | not run |
| macOS / Windows | any | untested (probe branches blocked); same code path |
First bad commit: 6bdd67c (#1792). b4b6d98 and v5.0.1 pass the same cells.
Suspect code
crates/socket-patch-core/src/patch/npm_bin.rs:89:is_listed_bin(...) || { lf_shebang && ... }. Listing alone overrides an on-disk CRLF shebang. The file bytes alone can't tell a fresh--no-bin-linksinstall from a tree an older rollback left with the raw CRLF original (After the #1756 fix, a CRLF-shebang npm bin that v5.0.1 already broke stays broken: apply reports "already patched", and repair and rollback keep the unrunnable CRLF shebang #1791), so the signal probably has to be whether npm linked the bin: anode_modules/.bin/<name>(or the parent's.bin) entry resolving to this file, or the effectivebin-linksconfig.crates/socket-patch-core/src/patch/npm_bin.rs:163(lists_bin): thedirectories.binbranch counts files npm links only for registry installs (from the packument's expandedbin).- Call sites:
crates/socket-patch-core/src/patch/apply.rs:1309,crates/socket-patch-core/src/patch/rollback.rs:815.
No probe runs: probe branches are still blocked for this routine.
Activity
npm bughunt run 53: verify #1791 fixed, file #1804
mikolalysenko commentedon Oct 29, 2026
[agent] Triage:
priority:p1(npm). Not a duplicate: #1791 was the opposite direction (a CRLF left in a bin npm did link) and #1792 fixed it by trusting thepackage.jsonlisting over on-disk evidence, which this report shows over-reaches when npm never linked the bin (bin-links=false, or adirectories.bin-only non-registry install). No open PR addresses it. Confirmed againstcrates/socket-patch-core/src/patch/npm_bin.rs(form_for_writeshort-circuits onis_listed_bin;needs_fixhas the same gate).Generated by Claude Code
mikolalysenko commentedon Oct 29, 2026
[agent] Claiming this issue (shared root cause: npm bin shebang form chosen from the package.json listing alone, without evidence npm linked the bin). Branch: agent/fix-npm-unlinked-bin-shebang. Claim-ID: 2026-10-29T01:20:38Z-884d7a
Generated by Claude Code
mikolalysenko commentedon Oct 29, 2026
[agent] Draft fix PR: #1805
Generated by Claude Code
Keep CRLF shebang on bins npm didn't link
Look for npm's bin link only where npm puts it
Only trust a bin shim that runs this file
Match a bin shim's path on whole segments
mikolalysenko commentedon Oct 29, 2026
[agent] npm bug-hunt run 54: two more shapes where npm leaves a listed bin unlinked, so its CRLF shebang stays. Both fail on main
ecd4924. Draft PR #1805 (5789c73) fixes both.1. Bin-name collision. Two direct deps both declare
"bin": {"x": "cli.js"}. In bin-links,link-gentlykeeps a process-wideseenset, andlink-binrunslinked && fixBin(absFrom). So only the package that wins.bin/xgetsfixBin, and the other keeps#!…\r\n. On main,applyon the losing package writes the LF form, androllbackthen isn't byte-exact.2. A bundled dep with
rebuild-bundle=false(npm install --no-rebuild-bundle). The depouterbundlesinner, whose bin has a CRLF shebang. With the defaultrebuild-bundle=true, arborist's#buildQueueswalks the bundle, linksinner's bin and runsfixBinon it (LF on disk). Main agrees and passes. Withfalse, there's noouter/node_modules/.bin, the CRLF stays, and main writes LF.--no-rebuild-bundle: main--no-rebuild-bundle: #1805fixBinon both packages)"fail" means
applywriteso d e \n cwhere the recordedafterHashhas\r\n,apply --checksays in sync, androllbackdiffers from thenpm installbytes at char 20. v5.0.1 rolls back byte-exact in the collision shape.Repro for the collision shape. It reuses the issue's staging steps (
stage= the.socket/manifest.jsonand blobs block from the repro above, aimed at the losing package):Not filing separately, since it's the same root cause. Related non-bug: if you
rm -rf node_modules/<pkg> && npm installwhile.bin/<name>survives,link-gentlyreturnsSKIP,fixBindoesn't run, and npm itself leaves a CRLF bin that exits 127. Main (and #1805, because the link exists) writes LF there, which fixes the bin. I'm treating the non-byte-exact rollback in that case as npm's own quirk, not a socket-patch bug.Generated by Claude Code