fix(desktop): stage and sign libnode dylib for Homebrew-installed Node#7803
fix(desktop): stage and sign libnode dylib for Homebrew-installed Node#7803formed2forge wants to merge 5 commits into
Conversation
…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 SummaryThis PR fixes a crash in
Confidence Score: 4/5Safe 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
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
Reviews (1): Last reviewed commit: "fix(desktop): address code review findin..." | Re-trigger Greptile |
| 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 |
There was a problem hiding this comment.
@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?
| 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 |
There was a problem hiding this comment.
$(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.
| 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!
Summary
Fixes #7802 —
run.shcrashes during agent runtime preparation when Node is installed via Homebrew.Homebrew's
nodebinary (v22+) is a small stub (~68KB) that dynamically loadslibnode.X.dylibat 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.shstage_local_node, useotool -Lto detect alibnode.X.dylibdependency, locate the dylib in the Homebrew prefix, copy it alongside the binary, andchmod u+wit (Homebrew installs it 0444, which would cause the downstreamcodesign --forceto fail with EACCES)desktop/run.shlibnode.*.dylibfound in the resource bundle before the final app bundle signature step — an unsigned dylib inside the bundle causescodesignto abort with "internal error in Code Signing subsystem"--options runtimeonly (no--entitlements) — macOS reads process entitlements exclusively from the main executable, not from loaded dylibs; the JIT permission comes from thenodebinary's own signaturedesktop/.gitignoreDesktop/Sources/Resources/libnode.*.dylib— the existing rule only covered the literalnodebinary; the 70MB dylib was an accidental-commit hazardTest plan
cd desktop && ./run.sh --yolocompletes successfully with Homebrew Node (v22+) installed[agent-runtime] Runtime validated: node=v26.0.0, agent dist and piMono files presentDesktop/Sources/Resources/libnode.*.dylibdoes not appear ingit statusafter a run--universal-nodepath (statically linked, no dylib staged) continues to work as before — new block only runs whenotool -Ldetects alibnodedependency; static nodejs.org builds have none🤖 Generated with Claude Code