Skip to content

Since #1792, agent apply and rollback rewrite a CRLF shebang that npm left alone (bin-links=false, or a directories.bin-only non-registry package), so rollback is no longer byte-exact #1804

Closed

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:

  1. bin-links=false (npm install --no-bin-links, or bin-links=false in .npmrc; used on filesystems without symlinks). There's no node_modules/.bin at all, and the bin file is installed byte-for-byte as published.
  2. A package that declares its bins only through directories.bin, installed from a non-registry spec (file: tarball here). npm 10 doesn't expand directories.bin from the tarball's package.json (arborist reads it with read-package-json-fast), so it doesn't link or fixBin those files, but lists_bin counts them as bins.

On main in both shapes:

  • apply writes the patched file with an LF shebang. That's neither the recorded afterHash bytes nor what npm would install for the patched package.
  • rollback then writes fixBin(beforeHash), an LF shebang. The file differs from what npm install wrote, so rollback is no longer byte-exact. On b4b6d98 (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_EXACT
repro.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, apply writes the recorded afterHash bytes and rollback restores the installed bytes exactly, as on b4b6d98. CLI_CONTRACT.md rollback: "restore original files".
  • Actual: apply writes fixBin(after) and rollback writes fixBin(before), because is_listed_bin short-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-links install 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: a node_modules/.bin/<name> (or the parent's .bin) entry resolving to this file, or the effective bin-links config.
  • crates/socket-patch-core/src/patch/npm_bin.rs:163 (lists_bin): the directories.bin branch counts files npm links only for registry installs (from the packument's expanded bin).
  • 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

  1. mikolalysenko commented on Oct 29, 2026

    @mikolalysenko
    CollaboratorAuthor

    [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 the package.json listing over on-disk evidence, which this report shows over-reaches when npm never linked the bin (bin-links=false, or a directories.bin-only non-registry install). No open PR addresses it. Confirmed against crates/socket-patch-core/src/patch/npm_bin.rs (form_for_write short-circuits on is_listed_bin; needs_fix has the same gate).


    Generated by Claude Code

  2. mikolalysenko commented on Oct 29, 2026

    @mikolalysenko
    CollaboratorAuthor

    [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

  3. mikolalysenko commented on Oct 29, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Draft fix PR: #1805


    Generated by Claude Code

  4. added 4 commits that reference this issue on Oct 29, 2026
    9e8951c
    871630f
    d2387bf
    5789c73
  5. mikolalysenko commented on Oct 29, 2026

    @mikolalysenko
    CollaboratorAuthor

    [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-gently keeps a process-wide seen set, and link-bin runs linked && fixBin(absFrom). So only the package that wins .bin/x gets fixBin, and the other keeps #!…\r\n. On main, apply on the losing package writes the LF form, and rollback then isn't byte-exact.

    2. A bundled dep with rebuild-bundle=false (npm install --no-rebuild-bundle). The dep outer bundles inner, whose bin has a CRLF shebang. With the default rebuild-bundle=true, arborist's #buildQueues walks the bundle, links inner's bin and runs fixBin on it (LF on disk). Main agrees and passes. With false, there's no outer/node_modules/.bin, the CRLF stays, and main writes LF.

    npm (Node 22.22.0, Linux) collision: main collision: #1805 --no-rebuild-bundle: main --no-rebuild-bundle: #1805 default bundle: main / #1805
    6.14.18 pass (npm 6 runs fixBin on both packages) not run fail not run pass / not run
    10.9.4 fail (2/2) pass fail (2/2) pass pass / pass
    12.2.1 fail pass fail pass pass / pass

    "fail" means apply writes o d e \n c where the recorded afterHash has \r\n, apply --check says in sync, and rollback differs from the npm install bytes 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.json and blobs block from the repro above, aimed at the losing package):

    mkpkg() { mkdir -p $1; printf '%s' "$2" > $1/package.json; printf '#!/usr/bin/env node\r\nconsole.log(1);\r\n' > $1/cli.js; (cd $1 && npm pack --silent >/dev/null); }
    mkpkg aaa '{"name":"aaa","version":"1.0.0","bin":{"x":"cli.js"}}'
    mkpkg bbb '{"name":"bbb","version":"1.0.0","bin":{"x":"cli.js"}}'
    mkdir proj && cd proj
    echo '{"name":"p","version":"1.0.0","dependencies":{"aaa":"file:../aaa/aaa-1.0.0.tgz","bbb":"file:../bbb/bbb-1.0.0.tgz"}}' > package.json
    npm install --no-audit --no-fund
    readlink node_modules/.bin/x                   # ../aaa/cli.js
    head -c 22 node_modules/bbb/cli.js | od -c     # \r\n kept: stage a patch for pkg:npm/bbb@1.0.0, then apply / rollback

    Not filing separately, since it's the same root cause. Related non-bug: if you rm -rf node_modules/<pkg> && npm install while .bin/<name> survives, link-gently returns SKIP, fixBin doesn'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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions