Skip to content

fix(desktop): stage and sign libnode dylib for Homebrew-installed Node#7803

Open
formed2forge wants to merge 5 commits into
BasedHardware:mainfrom
formed2forge:fix/desktop-homebrew-node-dylib
Open

fix(desktop): stage and sign libnode dylib for Homebrew-installed Node#7803
formed2forge wants to merge 5 commits into
BasedHardware:mainfrom
formed2forge:fix/desktop-homebrew-node-dylib

Conversation

@formed2forge

Copy link
Copy Markdown
Contributor

Summary

Fixes #7802run.sh crashes during agent runtime preparation when Node is installed via Homebrew.

Homebrew's node binary (v22+) is a small stub (~68KB) that dynamically loads libnode.X.dylib at startup via @loader_path. The official nodejs.org prebuilt binaries are statically linked, so this was never an issue until a developer had only Homebrew-installed Node.

Three files changed across four commits:

desktop/scripts/prepare-agent-runtime.sh

  • After copying the node stub in stage_local_node, use otool -L to detect a libnode.X.dylib dependency, locate the dylib in the Homebrew prefix, copy it alongside the binary, and chmod u+w it (Homebrew installs it 0444, which would cause the downstream codesign --force to fail with EACCES)

desktop/run.sh

  • Sign any libnode.*.dylib found in the resource bundle before the final app bundle signature step — an unsigned dylib inside the bundle causes codesign to abort with "internal error in Code Signing subsystem"
  • Sign the dylib with --options runtime only (no --entitlements) — macOS reads process entitlements exclusively from the main executable, not from loaded dylibs; the JIT permission comes from the node binary's own signature

desktop/.gitignore

  • Add Desktop/Sources/Resources/libnode.*.dylib — the existing rule only covered the literal node binary; the 70MB dylib was an accidental-commit hazard

Test plan

  • cd desktop && ./run.sh --yolo completes successfully with Homebrew Node (v22+) installed
  • Agent runtime validation passes: [agent-runtime] Runtime validated: node=v26.0.0, agent dist and piMono files present
  • App bundle signs without error: no "internal error in Code Signing subsystem"
  • Desktop/Sources/Resources/libnode.*.dylib does not appear in git status after a run
  • Unaffected: --universal-node path (statically linked, no dylib staged) continues to work as before — new block only runs when otool -L detects a libnode dependency; static nodejs.org builds have none

🤖 Generated with Claude Code

formed2forge and others added 5 commits June 10, 2026 11:04
…agent-runtime

Homebrew node (v22+) is a small stub binary that dlopen()s libnode.X.dylib
via @loader_path at startup. stage_local_node only copied the stub, so the
binary aborted immediately with "Library not loaded: @rpath/libnode.X.dylib"
when run from the Resources directory.

Fix: after copying the stub, detect a libnode dependency via otool, locate
the dylib in the Homebrew prefix, and copy it alongside the node binary so
@loader_path can resolve it at both the validation step and app runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The awk gsub left the tab from otool output before the filename,
causing the path search to find nothing. Use match/substr to extract
just the libnode.X.dylib token directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When node is staged from a Homebrew dynamic build, libnode.X.dylib
lands in the resource bundle. codesign rejects the app bundle if it
contains an unsigned dylib, causing "internal error in Code Signing
subsystem". Sign any libnode.*.dylib found alongside the node binary
before the final app bundle signature step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
libnode contains V8's JIT runtime. Signing it with --options runtime
but no entitlements triggers a Code Signing subsystem internal error
when the bundle is signed. Use the same Node.entitlements as the node
binary (allow-jit + allow-unsigned-executable-memory).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- chmod u+w the staged libnode dylib after cp -f: Homebrew installs
  it 0444, so codesign --force would fail with EACCES without this
- gitignore libnode.*.dylib in Desktop/Sources/Resources: only the
  `node` binary was previously ignored; the 70MB dylib was an
  accidental-commit hazard
- remove --entitlements from libnode dylib signing: macOS reads
  process entitlements only from the main executable, not from loaded
  dylibs; sign with --options runtime only and correct the comment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a crash in run.sh for developers who have Node.js installed via Homebrew (v22+), where the node binary is a small ~68 KB stub that dynamically loads libnode.X.dylib. The fix stages the dylib alongside the node binary, ensures it is writable for codesign, and signs it inside the app bundle before signing the node binary itself.

  • prepare-agent-runtime.sh: After copying the Homebrew node stub, uses otool -L to detect a libnode dependency, locates the dylib via candidate paths (Cellar-relative and Homebrew prefix), copies it alongside the node binary, and makes it writable (chmod u+w) so the downstream codesign --force step doesn't fail with EACCES.
  • run.sh: Adds a glob loop to sign any libnode.*.dylib in the node bundle dir before signing the node binary — the correct signing order — using --options runtime only (no --entitlements, as macOS reads entitlements exclusively from the main executable). Also refactors the repeated bundle dir path into a NODE_BUNDLE_DIR variable.
  • .gitignore: Extends the existing Resources exclusion to cover libnode.*.dylib, preventing the ~70 MB dylib from being accidentally committed.

Confidence Score: 4/5

Safe to merge for development workflows; the fix is correctly scoped to Homebrew-installed node and has no effect on the --universal-node release path.

The signing order, entitlements handling, and gitignore entry are all correct. The open question is whether the staged dylib actually resolves at runtime — modern Homebrew node stubs use @rpath/libnode.X.dylib, and the RPATH entries in the binary determine where dyld looks. If the only matching RPATH entry is an absolute /opt/homebrew/lib path, the staged copy in the bundle is redundant during development and would still fail on a machine without Homebrew.

desktop/scripts/prepare-agent-runtime.sh — the dylib candidate search and placement logic depends on an assumption about the node stub's RPATH entries that is worth verifying.

Important Files Changed

Filename Overview
desktop/scripts/prepare-agent-runtime.sh Adds Homebrew dylib detection and staging after copying the node stub. The correctness of the staged location depends on the binary's LC_RPATH entries matching @loader_path/; if they only contain @loader_path/../lib and an absolute Homebrew path, the staged copy doesn't actually serve bundle isolation on non-Homebrew machines.
desktop/run.sh Refactors node bundle dir into a variable and adds a glob loop to sign any libnode.*.dylib before signing the node binary — correct order and correct use of --options runtime without --entitlements for dylibs.
desktop/.gitignore Adds the libnode.*.dylib glob to prevent the ~70 MB Homebrew dylib from being accidentally committed alongside the existing node binary exclusion.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[stage_local_node called] --> B[cp node stub to NODE_RESOURCE]
    B --> C[otool -L node_bin]
    C --> D{libnode.X.dylib dependency found?}
    D -- No, static build --> E[log version, done]
    D -- Yes, Homebrew stub --> F[Search candidates:
1. node_bin_dir/../lib/libnode.X.dylib
2. node_bin_dir/libnode.X.dylib
3. brew --prefix/lib/libnode.X.dylib]
    F --> G{Found?}
    G -- No --> H[exit 1 with error]
    G -- Yes --> I[cp dylib alongside node
chmod u+w
xattr -cr]
    I --> E

    subgraph run.sh signing order
        J[NODE_BIN exists?] --> K[for libnode.*.dylib in bundle dir]
        K --> L{file exists?}
        L -- No --> M[continue]
        L -- Yes --> N[codesign --options runtime
no entitlements]
        N --> O[codesign node binary
with entitlements]
        O --> P[codesign app bundle]
    end
Loading

Reviews (1): Last reviewed commit: "fix(desktop): address code review findin..." | Re-trigger Greptile

Comment on lines +138 to +155
for candidate in \
"$node_bin_dir/../lib/$libnode_name" \
"$node_bin_dir/$libnode_name" \
"$(brew --prefix 2>/dev/null)/lib/$libnode_name"; do
if [ -f "$candidate" ]; then
libnode_src="$(realpath "$candidate")"
break
fi
done
if [ -z "$libnode_src" ]; then
echo "ERROR: node requires $libnode_name but it was not found near $node_bin or in Homebrew lib." >&2
exit 1
fi
cp -f "$libnode_src" "$(dirname "$NODE_RESOURCE")/$libnode_name"
chmod u+w "$(dirname "$NODE_RESOURCE")/$libnode_name"
xattr -cr "$(dirname "$NODE_RESOURCE")/$libnode_name" 2>/dev/null || true
log "Staged $libnode_name alongside node (Homebrew dynamic build)"
fi

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 @rpath vs @loader_path — staged path may not resolve at runtime

The PR description says the Homebrew stub references @loader_path/libnode.X.dylib, but a related upstream issue (nexu-io/open-design#1275) shows modern Homebrew node uses @rpath/libnode.X.dylib instead. With @rpath, dyld iterates the binary's LC_RPATH entries — which for a Homebrew node stub are typically @loader_path/../lib and the absolute /opt/homebrew/lib path.

Placing the dylib in the same directory ($(dirname "$NODE_RESOURCE")/) satisfies @loader_path/ in the RPATH only if that exact entry exists. If the RPATH has only @loader_path/../lib plus the absolute Homebrew path, the staged copy would be silently ignored and the binary would still resolve the dylib from /opt/homebrew/lib. For a fully self-contained bundle, the dylib needs to be placed to match an actual LC_RPATH entry, or the install name of the staged dylib needs to be patched with install_name_tool -add_rpath. Can you confirm that otool -l /opt/homebrew/bin/node | grep -A2 LC_RPATH shows @loader_path/ (without ../lib) as one of the entries?

Comment on lines +151 to +153
cp -f "$libnode_src" "$(dirname "$NODE_RESOURCE")/$libnode_name"
chmod u+w "$(dirname "$NODE_RESOURCE")/$libnode_name"
xattr -cr "$(dirname "$NODE_RESOURCE")/$libnode_name" 2>/dev/null || true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 $(dirname "$NODE_RESOURCE") is evaluated three times in a row. Capturing it in a local variable makes the intent clearer and avoids repeated subshell overhead.

Suggested change
cp -f "$libnode_src" "$(dirname "$NODE_RESOURCE")/$libnode_name"
chmod u+w "$(dirname "$NODE_RESOURCE")/$libnode_name"
xattr -cr "$(dirname "$NODE_RESOURCE")/$libnode_name" 2>/dev/null || true
local node_resource_dir
node_resource_dir="$(dirname "$NODE_RESOURCE")"
cp -f "$libnode_src" "$node_resource_dir/$libnode_name"
chmod u+w "$node_resource_dir/$libnode_name"
xattr -cr "$node_resource_dir/$libnode_name" 2>/dev/null || true

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@kodjima33 kodjima33 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Desktop bug fix (sign libnode dylib for Homebrew Node, #7802) — has merge conflicts, please rebase

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

run.sh fails to launch on macOS when Node is installed via Homebrew

2 participants