{"repo":"NousResearch/hermes-agent","public_id":"37692","display_label":"Nix build npm dependency hash fixes","summary":"These issues and pull requests address recurring Nix build breakage caused by outdated or mismatched npmDepsHash values for hermes-desktop-renderer, including attempts to eliminate manual hash maintenance via importNpmLock.","subsystem":null,"counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":48457},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-19T08:27:13+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":37692,"pr_number":48457,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":37692,"pr_number":48883,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":37692,"title":"[Bug]: Nix build broken — `hermes-desktop-renderer` npmDepsHash is out of date","url":null,"state":"closed"},{"kind":"pr","number":37700,"title":"fix(nix): update hermes-desktop-renderer npmDepsHash to match current lockfile","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":48213,"title":"fix(nix): hashless npm deps via importNpmLock","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#48457","number":48457},{"kind":"pr","value":"#48883","number":48883}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":48457,"title":"fix(nix): refresh npmDepsHash after the Electron 40.10.2 pin (#47792)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["nix/lib.nix"],"fix_hunks":[{"file":"nix/lib.nix","hunks":["@@ -21,7 +21,7 @@ let\n \n   # Single npm deps fetch from the workspace root lockfile.\n   # All workspace packages share this derivation.\n-  npmDepsHash = \"sha256-m9cjbjzi4SaFCjODfdrawS5e+1ag+MpRn528/upSNqo=\";\n+  npmDepsHash = \"sha256-kbjJksq7limRIYqP3DwI+GNgCXkG96tXcsQqmuEedxo=\";\n \n   npmDeps = pkgs.fetchNpmDeps {\n     inherit src;\n"]}]},{"kind":"pr","number":48883,"title":"fix(nix): hashless npm deps via importNpmLock (salvage #48213)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["nix/devShell.nix","nix/lib.nix","nix/packages.nix"],"fix_hunks":[{"file":"nix/devShell.nix","hunks":["@@ -12,7 +12,6 @@\n     let\n       packages = builtins.attrValues self'.packages;\n       hermesNpmLib = self'.packages.default.passthru.hermesNpmLib;\n-      fixLockfilesExe = pkgs.lib.getExe self'.packages.fix-lockfiles;\n \n       # Collect all packageJsonPath values from npm workspace packages.\n       npmPackageJsonPaths = builtins.filter (p: p != null) (","@@ -33,7 +32,7 @@\n         shellHook = ''\n           echo \"Hermes Agent dev shell\"\n           ${combinedNonNpm}\n-          ${hermesNpmLib.mkNpmDevShellHook npmPackageJsonPaths fixLockfilesExe}\n+          ${hermesNpmLib.mkNpmDevShellHook npmPackageJsonPaths}\n           echo \"Ready. Run 'hermes' to start.\"\n         '';\n       };"]},{"file":"nix/lib.nix","hunks":["@@ -2,8 +2,7 @@\n #\n # All npm packages in this repo are workspace members sharing a single\n # root package-lock.json.  mkNpmPassthru provides the shared src, npmDeps,\n-# npmRoot, and npmDepsFetcherVersion so individual .nix files don't\n-# duplicate them.  One hash to rule them all.\n+# npmRoot, and npmConfigHook so individual .nix files don't duplicate them.\n #\n # mkNpmPassthru returns packageJsonPath (e.g. \"ui-tui/package.json\")\n # instead of a per-package devShellHook.  The root devshell hook","@@ -19,28 +18,19 @@ let\n   # The workspace root — where the single package-lock.json lives.\n   src = ../.;\n \n-  # Single npm deps fetch from the workspace root lockfile.\n-  # All workspace packages share this derivation.\n-  npmDepsHash = \"sha256-kbjJksq7limRIYqP3DwI+GNgCXkG96tXcsQqmuEedxo=\";\n-\n-  npmDeps = pkgs.fetchNpmDeps {\n-    inherit src;\n-    fetcherVersion = 2;\n-    hash = npmDepsHash;\n-  };\n+  # npm dependencies for the workspace, shared by all members. importNpmLock\n+  # resolves each package from the lockfile's own `integrity` hashes, so the\n+  # lockfile is the single source of truth — no separate dependency hash to\n+  # keep in sync with it.\n+  npmDeps = pkgs.importNpmLock.importNpmLock { npmRoot = src; };\n in\n {\n   # Returns a buildNpmPackage-compatible attrs set that provides:\n-  #   src, npmDeps, npmRoot, npmDepsFetcherVersion\n-  #   patchPhase             — ensures root lockfile has exactly one trailing newline\n-  #   nativeBuildInputs      — [ updateLockfileScript ] (list, prepend with ++ for more)\n-  #   passthru.packageJsonPath — relative path to this workspace's package.json\n-  #   nodejs                 — fixed nodejs version for all packages we use in the repo\n-  #\n-  # NOTE: npmConfigHook runs `diff` between the source lockfile and the\n-  # npm-deps cache lockfile. fetchNpmDeps preserves whatever trailing\n-  # newlines the lockfile has. The patchPhase normalizes to exactly one\n-  # trailing newline so both sides always match.\n+  #   src, npmDeps, npmRoot      — workspace source + importNpmLock dep set\n+  #   npmConfigHook              — importNpmLock's offline `npm install` hook\n+  #   nativeBuildInputs          — [ updateLockfileScript ] (list, prepend with ++ for more)\n+  #   passthru.packageJsonPath   — relative path to this workspace's package.json\n+  #   nodejs                     — fixed nodejs version for all packages we use in the repo\n   #\n   # Usage:\n   #   npm = hermesNpmLib.mkNpmPassthru { folder = \"ui-tui\"; attr = \"tui\"; pname = \"hermes-tui\"; };","@@ -62,35 +52,15 @@ in\n     in\n     {\n       inherit src npmDeps nodejs;\n+      # importNpmLock's hook installs the rewritten lockfile (every `resolved`\n+      # rewritten to a /nix/store file: path) into the unpacked workspace and\n+      # runs `npm install` offline, so every workspace member's dependencies\n+      # resolve without network access.\n+      npmConfigHook = pkgs.importNpmLock.npmConfigHook;\n       npmRoot = \".\";\n-      npmDepsFetcherVersion = 2;\n \n       ELECTRON_SKIP_BINARY_DOWNLOAD = 1;\n \n-      patchPhase = ''\n-        runHook prePatch\n-        # Normalize trailing newlines on the root lockfile so source and\n-        # npm-deps always match, regardless of what fetchNpmDeps preserves.\n-        sed -i -z 's/\\\\n*$/\\\\n/' package-lock.json\n-\n-        # Make npmConfigHook's byte-for-byte diff newline-agnostic by\n-        # replacing its hardcoded /nix/store/.../diff with a wrapper that\n-        # normalizes trailing newlines on both sides before comparing.\n-        mkdir -p \"$TMPDIR/bin\"\n-        cat > \"$TMPDIR/bin/diff\" << DIFFWRAP\n-        #!/bin/sh\n-        f1=\\\\$(mktemp) && sed -z 's/\\\\n*$/\\\\n/' \"\\\\$1\" > \"\\\\$f1\"\n-        f2=\\\\$(mktemp) && sed -z 's/\\\\n*$/\\\\n/' \"\\\\$2\" > \"\\\\$f2\"\n-        ${pkgs.diffutils}/bin/diff \"\\\\$f1\" \"\\\\$f2\" && rc=0 || rc=\\\\$?\n-        rm -f \"\\\\$f1\" \"\\\\$f2\"\n-        exit \\\\$rc\n-        DIFFWRAP\n-        chmod +x \"$TMPDIR/bin/diff\"\n-        export PATH=\"$TMPDIR/bin:$PATH\"\n-\n-        runHook postPatch\n-      '';\n-\n       nativeBuildInputs = [\n         (pkgs.writeShellScriptBin \"update_${attr}_lockfile\" ''\n           set -euox pipefail","@@ -104,7 +74,6 @@ in\n           CI=true ${pkgs.lib.getExe' nodejs \"npm\"} install --workspaces\n           ${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json\n \n-          # Hash lives in lib.nix — just rebuild to verify.\n           nix build .#${attr}\n           echo \"Lockfile updated and build verified for .#${attr}\"\n         '')","@@ -120,12 +89,9 @@ in\n   # Takes a list of package.json relative paths (from mkNpmPassthru .passthru.packageJsonPath),\n   # stamps all of them, and if any changed:\n   #   1. Runs `npm i --package-lock-only` from root to update the lockfile\n-  #   2. If the lockfile changed, runs `npm ci` + fix-lockfiles\n-  #\n-  # fixLockfilesExe: absolute path to the fix-lockfiles binary\n-  # (from pkgs.lib.getExe self'.packages.fix-lockfiles in devShell.nix).\n+  #   2. If the lockfile changed, runs `npm ci`\n   mkNpmDevShellHook =\n-    packageJsonPaths: fixLockfilesExe:\n+    packageJsonPaths:\n     pkgs.writeShellScript \"npm-dev-hook\" ''\n       REPO_ROOT=$(git rev-parse --show-toplevel)\n ","@@ -158,172 +124,4 @@ in\n         echo \"$LOCK_STAMP_VALUE\" > \"$LOCK_STAMP\"\n       fi\n     '';\n-\n-  # Build `fix-lockfiles` bin that checks/updates the single npmDepsHash\n-  #   fix-lockfiles --check   # exit 1 if any hash is stale\n-  #   fix-lockfiles --apply   # rewrite stale hashes in place\n-  #   fix-lockfiles           # alias of --apply\n-  # Writes machine-readable fields (stale, changed, report) to $GITHUB_OUTPUT\n-  # when set, so CI workflows can post a sticky PR comment directly.\n-  mkFixLockfiles =\n-    {\n-      attr, # flake package attr for fallback verification build, e.g. \"tui\"\n-    }:\n-    pkgs.writeShellScriptBin \"fix-lockfiles\" ''\n-      set -uox pipefail\n-      MODE=\"''${1:---apply}\"\n-      case \"$MODE\" in\n-        --check|--apply) ;;\n-        -h|--help)\n-          echo \"usage: fix-lockfiles [--check|--apply]\"\n-          exit 0 ;;\n-        *)\n-          echo \"usage: fix-lockfiles [--check|--apply]\" >&2\n-          exit 2 ;;\n-      esac\n-\n-      REPO_ROOT=\"$(git rev-parse --show-toplevel)\"\n-      cd \"$REPO_ROOT\"\n-\n-      # When running in GH Actions, emit Markdown links in the report pointing\n-      # at the offending line of the nix file (and the lockfile) at the exact\n-      # commit that was checked. LINK_SHA should be set by the workflow to the\n-      # PR head SHA; falls back to GITHUB_SHA (which on pull_request is the\n-      # test-merge commit, still browseable).\n-      LINK_SERVER=\"''${GITHUB_SERVER_URL:-https://github.com}\"\n-      LINK_REPO=\"''${GITHUB_REPOSITORY:-}\"\n-      LINK_SHA=\"''${LINK_SHA:-''${GITHUB_SHA:-}}\"\n-\n-      STALE=0\n-      FIXED=0\n-      REPORT=\"\"\n-\n-      # All workspace packages share the root package-lock.json, so\n-      # we only need to check the hash once.\n-      LOCK_FILE=\"package-lock.json\"\n-      LIB_FILE=\"nix/lib.nix\"\n-      NEW_HASH=$(${pkgs.lib.getExe pkgs.prefetch-npm-deps} \"$LOCK_FILE\" 2>/dev/null)\n-      if [ -z \"$NEW_HASH\" ]; then\n-        echo \"prefetch-npm-deps failed, falling back to nix build\" >&2\n-        OUTPUT=$(nix build \".#${attr}.npmDeps\" --no-link --print-build-logs 2>&1)\n-        STATUS=$?\n-        if [ \"$STATUS\" -eq 0 ]; then\n-          echo \"ok (via nix build)\"\n-          exit 0\n-        fi\n-        NEW_HASH=$(echo \"$OUTPUT\" | awk '/got:/ {print $2; exit}')\n-        if [ -z \"$NEW_HASH\" ]; then\n-          if echo \"$OUTPUT\" | grep -qE \"throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid\"; then\n-            echo \"skipped (transient cache failure — see primary nix build for real status)\" >&2\n-            echo \"$OUTPUT\" | tail -8 >&2\n-            exit 0\n-          fi\n-          echo \"build failed with no hash mismatch:\" >&2\n-          echo \"$OUTPUT\" | tail -40 >&2\n-          exit 1\n-        fi\n-      fi\n-\n-      OLD_HASH=$(grep -oE 'npmDepsHash = \"sha256-[^\"]+\"' \"$LIB_FILE\" | head -1 \\\n-        | sed -E 's/npmDepsHash = \"(.*)\"/\\1/')\n-\n-      # prefetch-npm-deps says the hash already matches — but it only hashes the\n-      # lockfile *contents* and can disagree with fetchNpmDeps + npmConfigHook,\n-      # which validate the full source lockfile against the realized deps cache.\n-      # Trusting prefetch alone produced false \"ok\" results while the actual\n-      # build was broken (e.g. lockfile engines/os/cpu fields the pinned nixpkgs\n-      # strips from the deps cache, tripping npmConfigHook). So when prefetch\n-      # claims the hash is current, confirm with a real consumer build before\n-      # believing it.\n-      if [ \"$NEW_HASH\" = \"$OLD_HASH\" ]; then\n-        if VERIFY_OUT=$(nix build \".#${attr}\" --no-link --print-build-logs 2>&1); then\n-          echo \"ok\"\n-          if [ -n \"''${GITHUB_OUTPUT:-}\" ]; then\n-            { echo \"stale=false\"; echo \"changed=false\"; } >> \"$GITHUB_OUTPUT\"\n-          fi\n-          exit 0\n-        fi\n-        # Build failed despite a matching hash. A fixed-output 'got:' means\n-        # prefetch genuinely disagreed with fetchNpmDeps — adopt the real hash\n-        # and fall through to the stale-handling path below.\n-        CORRECT_HASH=$(echo \"$VERIFY_OUT\" | awk '/got:/ {print $2; exit}')\n-        if [ -n \"$CORRECT_HASH\" ]; then\n-          echo \"prefetch-npm-deps reported current ($OLD_HASH) but fetchNpmDeps wants $CORRECT_HASH\" >&2\n-          NEW_HASH=\"$CORRECT_HASH\"\n-        elif echo \"$VERIFY_OUT\" | grep -qE \"throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid\"; then\n-          echo \"skipped (transient cache failure — see primary nix build for real status)\" >&2\n-          echo \"$VERIFY_OUT\" | tail -8 >&2\n-          exit 0\n-        else\n-          # Not a stale-hash problem — surface it honestly instead of \"ok\".\n-          echo \"::error::nix build .#${attr} failed and it is NOT a stale npmDepsHash (no 'got:' hash in output).\" >&2\n-          echo \"The committed lockfile may be incompatible with the pinned nixpkgs\" >&2\n-          echo \"(e.g. engines/os/cpu fields that prefetch-npm-deps strips from the\" >&2\n-          echo \"deps cache, tripping npmConfigHook). fix-lockfiles cannot repair this.\" >&2\n-          echo \"$VERIFY_OUT\" | tail -40 >&2\n-          if [ -n \"''${GITHUB_OUTPUT:-}\" ]; then\n-            { echo \"stale=false\"; echo \"changed=false\"; } >> \"$GITHUB_OUTPUT\"\n-          fi\n-          exit 1\n-        fi\n-      fi\n-\n-      HASH_LINE=$(grep -n 'npmDepsHash = \"sha256-' \"$LIB_FILE\" | head -1 | cut -d: -f1)\n-      echo \"stale: $LIB_FILE:$HASH_LINE $OLD_HASH -> $NEW_HASH\"\n-      STALE=1\n-\n-      if [ -n \"$LINK_REPO\" ] && [ -n \"$LINK_SHA\" ]; then\n-        LIB_URL=\"$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$LIB_FILE#L$HASH_LINE\"\n-        LOCK_URL=\"$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$LOCK_FILE\"\n-        REPORT=\"- [\\`$LIB_FILE:$HASH_LINE\\`]($LIB_URL): \\`$OLD_HASH\\` → \\`$NEW_HASH\\` — lockfile: [\\`$LOCK_FILE\\`]($LOCK_URL)\"$'\\\\n'\n-      else\n-        REPORT=\"- \\`$LIB_FILE:$HASH_LINE\\`: \\`$OLD_HASH\\` → \\`$NEW_HASH\\`\"$'\\\\n'\n-      fi\n-\n-      if [ \"$MODE\" = \"--apply\" ]; then\n-        sed -i -E \"s|npmDepsHash = \\\"sha256-[^\\\"]+\\\";|npmDepsHash = \\\"$NEW_HASH\\\";|\" \"$LIB_FILE\"\n-        if ! nix build \".#${attr}.npmDeps\" --no-link --print-build-logs 2>/dev/null; then\n-          # prefetch-npm-deps may disagree with fetchNpmDeps (it hashes\n-          # the lockfile contents, not the full source tree).  Extract the\n-          # correct hash from the nix build error and retry.\n-          RETRY_OUTPUT=$(nix build \".#${attr}.npmDeps\" --no-link --print-build-logs 2>&1)\n-          CORRECT_HASH=$(echo \"$RETRY_OUTPUT\" | awk '/got:/ {print $2; exit}')\n-          if [ -n \"$CORRECT_HASH\" ]; then\n-            echo \"prefetch-npm-deps gave $NEW_HASH but nix wants $CORRECT_HASH — retrying\" >&2\n-            sed -i -E \"s|npmDepsHash = \\\"sha256-[^\\\"]+\\\";|npmDepsHash = \\\"$CORRECT_HASH\\\";|\" \"$LIB_FILE\"\n-            if ! nix build \".#${attr}.npmDeps\" --no-link --print-build-logs; then\n-              echo \"verification build failed after hash retry\" >&2\n-              exit 1\n-            fi\n-            NEW_HASH=\"$CORRECT_HASH\"\n-          else\n-            echo \"verification build failed after hash update\" >&2\n-            exit 1\n-          fi\n-        fi\n-        FIXED=1\n-        echo \"fixed\"\n-      fi\n-\n-      if [ -n \"''${GITHUB_OUTPUT:-}\" ]; then\n-        {\n-          [ \"$STALE\" -eq 1 ] && echo \"stale=true\" || echo \"stale=false\"\n-          [ \"$FIXED\" -eq 1 ] && echo \"changed=true\" || echo \"changed=false\"\n-          if [ -n \"$REPORT\" ]; then\n-            echo \"report<<REPORT_EOF\"\n-            printf \"%s\" \"$REPORT\"\n-            echo \"REPORT_EOF\"\n-          fi\n-        } >> \"$GITHUB_OUTPUT\"\n-      fi\n-\n-      if [ \"$STALE\" -eq 1 ] && [ \"$MODE\" = \"--check\" ]; then\n-        echo\n-        echo \"Stale lockfile hash detected. Run:\"\n-        echo \"  nix run .#fix-lockfiles\"\n-        exit 1\n-      fi\n-\n-      exit 0\n-    '';\n }"]},{"file":"nix/packages.nix","hunks":["@@ -50,8 +50,6 @@\n         tui = hermesAgent.hermesTui;\n         web = hermesAgent.hermesWeb;\n         desktop = hermesAgent.hermesDesktop;\n-\n-        fix-lockfiles = hermesAgent.hermesNpmLib.mkFixLockfiles { attr = \"tui\"; };\n       };\n     };\n }\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}