Skip to content

fix(daemon): resolve Homebrew Cellar path to stable symlink for gateway install#32185

Merged
steipete merged 3 commits intoopenclaw:mainfrom
scoootscooob:fix/homebrew-stable-symlink-path
Mar 2, 2026
Merged

fix(daemon): resolve Homebrew Cellar path to stable symlink for gateway install#32185
steipete merged 3 commits intoopenclaw:mainfrom
scoootscooob:fix/homebrew-stable-symlink-path

Conversation

@scoootscooob
Copy link
Contributor

Summary

  • openclaw gateway install writes process.execPath into the LaunchAgent plist. Under Homebrew Node, this resolves to the versioned Cellar path (e.g. /opt/homebrew/Cellar/node/25.7.0/bin/node) which breaks when Homebrew upgrades Node.
  • Added resolveStableNodePath() that detects Cellar paths and resolves them to the stable Homebrew symlink (/opt/homebrew/bin/node) which Homebrew updates automatically during upgrades.
  • Handles both Apple Silicon (/opt/homebrew/Cellar/...) and Intel Mac (/usr/local/Cellar/...) paths. Falls back gracefully to the original path if the symlink doesn't exist.

Closes #32182

Test plan

  • New resolveStableNodePath unit tests (5 cases): Cellar → symlink, Intel Mac Cellar → symlink, missing symlink fallback, non-Cellar passthrough, system path passthrough
  • New integration test: resolvePreferredNodePath resolves Cellar execPath to stable symlink
  • All 15 runtime-paths tests pass

🤖 Generated with Claude Code

@openclaw-barnacle openclaw-barnacle bot added gateway Gateway runtime size: S experienced-contributor Contributor with 10+ merged PRs labels Mar 2, 2026
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 2, 2026

Greptile Summary

This PR fixes a real-world breakage where openclaw gateway install embeds the versioned Homebrew Cellar path for Node (e.g. /opt/homebrew/Cellar/node/25.7.0/bin/node) into the LaunchAgent plist, causing the daemon to fail after a brew upgrade node. The new resolveStableNodePath helper detects Cellar paths and redirects them to the stable Homebrew symlink (/opt/homebrew/bin/node on Apple Silicon, /usr/local/bin/node on Intel), falling back gracefully when the symlink doesn't exist.

Key concerns:

  • Versioned formula mismatch (logic bug): The regex ^(.+?)\/Cellar\/[^/]+\/[^/]+\/bin\/node$ matches any Homebrew formula name, including node@22, node@20, etc. For those formulas, the correct stable path is the keg path (e.g. /opt/homebrew/opt/node@22/bin/node), not <prefix>/bin/node. If a user runs openclaw gateway install with node@22 while the default node formula (a different version) is also linked at /opt/homebrew/bin/node, the function will silently return the wrong Node binary path. Restricting the match to the unversioned node formula — or resolving versioned formulas to their opt keg path — would close this gap.
  • Test coverage: There is no test case for the node@22 / versioned-formula scenario described above.

Confidence Score: 3/5

  • Safe for users of the default node formula; carries a silent misbehavior risk for users running versioned Homebrew formulas like node@22 alongside the default node.
  • The fix correctly addresses the reported Cellar-path breakage for the common case (default node formula). However, the regex over-matches versioned formulas and can substitute a different Node version into the plist without any warning. This is a correctness issue that affects a realistic user configuration, lowering confidence from 5 to 3.
  • src/daemon/runtime-paths.ts — specifically the regex in resolveStableNodePath and how it handles versioned Homebrew formulas.

Last reviewed commit: 0597772

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

2 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment on lines +163 to +167
const cellarMatch = nodePath.match(/^(.+?)\/Cellar\/[^/]+\/[^/]+\/bin\/node$/);
if (!cellarMatch) {
return nodePath;
}
const stablePath = `${cellarMatch[1]}/bin/node`;
Copy link
Contributor

Choose a reason for hiding this comment

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

Incorrect stable path for versioned Homebrew formulas (node@22, node@20, etc.)

The regex captures the prefix before /Cellar/ and always resolves to <prefix>/bin/node. For the default node formula this is correct (/opt/homebrew/bin/node), but for versioned formulas like node@22 the Homebrew-managed stable path is the keg path — e.g. /opt/homebrew/opt/node@22/bin/node — not /opt/homebrew/bin/node.

Problematic scenario: A user runs openclaw gateway install with node@22 as their active runtime (e.g. process.execPath = /opt/homebrew/Cellar/node@22/22.12.0/bin/node) while the default node formula (v24+) is also installed and linked at /opt/homebrew/bin/node. In this case:

  1. The regex matches and stablePath becomes /opt/homebrew/bin/node.
  2. fs.access succeeds because the default Node binary exists there.
  3. The function returns /opt/homebrew/bin/node (pointing to Node 24+) instead of the user's intended Node 22 binary.
  4. The plist ends up referencing the wrong Node binary entirely.

A more robust approach would capture the formula name and resolve to the keg path for versioned formulas, or restrict the match to only the unversioned node formula:

// Option A – restrict to the unversioned `node` formula only, letting versioned
// formulas fall back to their original Cellar paths (safe, no false substitution):
const cellarMatch = nodePath.match(/^(.+?)\/Cellar\/node\/[^/]+\/bin\/node$/);

// Option B – resolve versioned formulas to their keg path instead:
const cellarMatch = nodePath.match(/^(.+?)\/Cellar\/(node(?:@\d+)?)\/[^/]+\/bin\/node$/);
if (!cellarMatch) return nodePath;
const [, prefix, formula] = cellarMatch;
const stablePath = formula === "node"
  ? `${prefix}/bin/node`
  : `${prefix}/opt/${formula}/bin/node`;
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/daemon/runtime-paths.ts
Line: 163-167

Comment:
**Incorrect stable path for versioned Homebrew formulas (`node@22`, `node@20`, etc.)**

The regex captures the prefix before `/Cellar/` and always resolves to `<prefix>/bin/node`. For the default `node` formula this is correct (`/opt/homebrew/bin/node`), but for versioned formulas like `node@22` the Homebrew-managed stable path is the keg path — e.g. `/opt/homebrew/opt/node@22/bin/node` — not `/opt/homebrew/bin/node`.

**Problematic scenario:** A user runs `openclaw gateway install` with `node@22` as their active runtime (e.g. `process.execPath` = `/opt/homebrew/Cellar/node@22/22.12.0/bin/node`) while the default `node` formula (v24+) is also installed and linked at `/opt/homebrew/bin/node`. In this case:

1. The regex matches and `stablePath` becomes `/opt/homebrew/bin/node`.
2. `fs.access` succeeds because the *default* Node binary exists there.
3. The function returns `/opt/homebrew/bin/node` (pointing to Node 24+) instead of the user's intended Node 22 binary.
4. The plist ends up referencing the wrong Node binary entirely.

A more robust approach would capture the formula name and resolve to the keg path for versioned formulas, or restrict the match to only the unversioned `node` formula:

```typescript
// Option A – restrict to the unversioned `node` formula only, letting versioned
// formulas fall back to their original Cellar paths (safe, no false substitution):
const cellarMatch = nodePath.match(/^(.+?)\/Cellar\/node\/[^/]+\/bin\/node$/);

// Option B – resolve versioned formulas to their keg path instead:
const cellarMatch = nodePath.match(/^(.+?)\/Cellar\/(node(?:@\d+)?)\/[^/]+\/bin\/node$/);
if (!cellarMatch) return nodePath;
const [, prefix, formula] = cellarMatch;
const stablePath = formula === "node"
  ? `${prefix}/bin/node`
  : `${prefix}/opt/${formula}/bin/node`;
```

How can I resolve this? If you propose a fix, please make it concise.

scoootscooob and others added 3 commits March 2, 2026 22:13
…ay install

When `openclaw gateway install` runs under Homebrew Node, `process.execPath`
resolves to the versioned Cellar path (e.g. /opt/homebrew/Cellar/node/25.7.0/bin/node).
This path breaks when Homebrew upgrades Node, silently killing the gateway daemon.

Resolve Cellar paths to the stable Homebrew symlink (/opt/homebrew/bin/node)
which Homebrew updates automatically during upgrades.

Closes openclaw#32182

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…olution

Address review feedback: versioned Homebrew formulas (node@22, node@20)
use keg-only paths where the stable symlink is at <prefix>/opt/<formula>/bin/node,
not <prefix>/bin/node. Updated resolveStableNodePath to:

1. Try <prefix>/opt/<formula>/bin/node first (works for both default + versioned)
2. Fall back to <prefix>/bin/node for the default "node" formula
3. Return the original Cellar path if neither stable path exists

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@steipete steipete force-pushed the fix/homebrew-stable-symlink-path branch from b9d617c to 9810508 Compare March 2, 2026 22:15
@steipete steipete merged commit 4815572 into openclaw:main Mar 2, 2026
19 of 21 checks passed
@steipete
Copy link
Contributor

steipete commented Mar 2, 2026

Landed via temp rebase onto main.

  • Gate: pnpm test src/daemon/runtime-paths.test.ts
  • Land commit: 9810508
  • Merge commit: 8cc5ed845b1683ec399a1ccde56e4433a15495ec

Thanks @scoootscooob!

@steipete
Copy link
Contributor

steipete commented Mar 2, 2026

Correction with final SHAs:

  • Gate: pnpm test src/daemon/runtime-paths.test.ts
  • Land commit: 9810508
  • Merge commit: 4815572

Thanks @scoootscooob!

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

Labels

experienced-contributor Contributor with 10+ merged PRs gateway Gateway runtime size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

gateway install: use stable Homebrew symlink path instead of versioned Cellar path

2 participants