Back to Blog
Open Source

How a Sneaky Symlink Bug Broke OpenClaw for Thousands of Devs

February 12, 20268 min readTypeScript, Node.js, Open Source, Symlinks, nvm

The Problem

So here's the deal — you install OpenClaw globally with npm, fire up the Control UI dashboard, and get slapped with a 'Missing Control UI assets' error. The files are literally right there in the package. What gives? Turns out, if you're using nvm, fnm, n, or Homebrew to manage your Node versions (like most of us do), the whole thing falls apart. This wasn't some edge case either — the GitHub issue had 10+ upvotes and 29 comments from frustrated devs across macOS, Linux, and Windows. Even the maintainer admitted they'd never tested with symlink-based version managers.

Digging Into the Root Cause

Here's where it gets interesting. When you run a CLI tool installed through nvm, Node.js sets process.argv[1] to the symlink path — something like ~/.nvm/versions/node/v22/bin/openclaw — not the actual file it points to. The code in candidateDirsFromArgv1() calls path.resolve() to normalize this, but here's the catch: path.resolve() doesn't follow symlinks. It just cleans up relative path segments. So the code starts walking up from the nvm bin/ folder looking for package.json, never finds it, and boom — "assets not found." Classic case of something that looks totally fine until you realize the input isn't what you think it is.

typescript
function candidateDirsFromArgv1(argv1: string): string[] {
  const normalized = path.resolve(argv1);
  const candidates = [path.dirname(normalized)];
  // .bin logic only — never reaches the real package root
  // when argv1 is a symlink!
}

The Fix

The fix turned out to be surprisingly simple — just add an fs.realpathSync() call after path.resolve() to actually follow the symlink to where the file really lives. If the real path is different from the symlink path, we toss its directory into the candidate list for package root discovery. I wrapped it in a try/catch so if anything goes wrong, the original behavior stays exactly the same — zero risk of breaking existing setups. The best part? This pattern was already used elsewhere in the codebase for execPath resolution, so it felt right at home. I also cleaned up the async resolver's API to accept moduleUrl, keeping things consistent with the sync version.

typescript
function candidateDirsFromArgv1(argv1: string): string[] {
  const normalized = path.resolve(argv1);
  const candidates = [path.dirname(normalized)];

  // Resolve symlinks for version managers (nvm, fnm, n, Homebrew)
  try {
    const resolved = fsSync.realpathSync(normalized);
    if (resolved !== normalized) {
      candidates.push(path.dirname(resolved));
    }
  } catch { /* keep original candidates */ }

  // .bin logic...
}

Testing & Validation

You can't ship a fix without tests, right? I wrote three test cases that create actual symlinks using fs.symlinkSync with relative targets — basically mimicking exactly how nvm and fnm set things up in the real world. They cover all three resolution paths: finding the package root, finding the Control UI root, and resolving the dist index path through a symlink. I also made sure they skip gracefully on Windows CI where you might not have symlink privileges, so they won't randomly break the build.

CheckResult
Vitest (17/17 tests)All pass (14 existing + 3 new)
tsgo --noEmitClean — no type errors
oxlint (3 changed files)0 warnings, 0 errors
oxfmt --checkAll correctly formatted
git statusClean working tree

Platform Coverage

This is the part that made me smile. One tiny realpathSync call and suddenly every major version manager just works — nvm, fnm, n, Homebrew, you name it. And for setups that were already working fine (asdf, Volta, plain npm), it's a no-op. No regressions, no surprises.

Platform / ManagerMechanismStatus
nvm (Linux/macOS)realpathSync resolves bin symlinkFixed
fnm (Linux/macOS)realpathSync resolves double symlinkFixed
n (Linux/macOS)realpathSync resolves bin symlinkFixed
Homebrew/LinuxbrewrealpathSync resolves cellar symlinkFixed
asdf (exec-based)argv1 is already real path; no-opAlready works
Volta (binary launcher)argv1 is already real path; no-opAlready works
Standard npm globalrealpathSync returns same path; no-opNo regression

Key Technical Decisions

A few choices I want to call out, because they weren't accidental:

  • I put the fix at the lowest level (candidateDirsFromArgv1) so every caller gets it for free — sync resolvers, async resolvers, health checks, build triggers, everything.
  • For the async resolver API, I went with a string | options object union type. It's fully backward-compatible — existing callers don't need to change a thing.
  • Test symlinks use relative targets (../real-pkg/openclaw.mjs) instead of absolute paths, because that's how nvm and fnm actually create their symlinks.
  • Symlink creation in tests is wrapped with try/catch + skip for Windows, because not every CI runner has symlink privileges and I didn't want flaky builds.

What I Took Away From This

A few lessons that stuck with me. First — always check the actual runtime values, not just the code. path.resolve() looks perfectly correct in a code review, but the moment argv[1] is a symlink, it silently does the wrong thing. Second, before you write something new, look at what's already there. The codebase already used realpathSync for a similar purpose, so my fix felt natural to the maintainers. Third, defensive coding is your friend. That try/catch means my change literally can't break anything — it can only help. Shipped the PR with conventional commits, used the project's Fix template, and included AI-assisted disclosure as their CONTRIBUTING.md requires. Clean and by the book.