From bfbeea0f2034a96e38c93ffb1b6ee03bf4f7384b Mon Sep 17 00:00:00 2001 From: George Zhang Date: Fri, 23 Jan 2026 19:52:26 +0800 Subject: [PATCH] daemon: prefer symlinked paths over realpath for stable service configs (#1505) When installing the LaunchAgent/systemd service, the CLI was using fs.realpath() to resolve the entry.js path, which converted stable symlinked paths (e.g. node_modules/clawdbot) into version-specific paths (e.g. .pnpm/clawdbot@X.Y.Z/...). This caused the service to break after pnpm updates because the old versioned path no longer exists, even though the symlink still works. Now we prefer the original (symlinked) path when it's valid, keeping service configs stable across package version updates. --- src/daemon/program-args.test.ts | 20 ++++++++++++++++++++ src/daemon/program-args.ts | 14 ++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/daemon/program-args.test.ts b/src/daemon/program-args.test.ts index 49d93c65f..efddb49ba 100644 --- a/src/daemon/program-args.test.ts +++ b/src/daemon/program-args.test.ts @@ -45,6 +45,26 @@ describe("resolveGatewayProgramArguments", () => { ]); }); + it("prefers symlinked path over realpath for stable service config", async () => { + // Simulates pnpm global install where node_modules/clawdbot is a symlink + // to .pnpm/clawdbot@X.Y.Z/node_modules/clawdbot + const symlinkPath = path.resolve( + "/Users/test/Library/pnpm/global/5/node_modules/clawdbot/dist/entry.js", + ); + const realpathResolved = path.resolve( + "/Users/test/Library/pnpm/global/5/node_modules/.pnpm/clawdbot@2026.1.21-2/node_modules/clawdbot/dist/entry.js", + ); + process.argv = ["node", symlinkPath]; + fsMocks.realpath.mockResolvedValue(realpathResolved); + fsMocks.access.mockResolvedValue(undefined); // Both paths exist + + const result = await resolveGatewayProgramArguments({ port: 18789 }); + + // Should use the symlinked path, not the realpath-resolved versioned path + expect(result.programArguments[1]).toBe(symlinkPath); + expect(result.programArguments[1]).not.toContain("@2026.1.21-2"); + }); + it("falls back to node_modules package dist when .bin path is not resolved", async () => { const argv1 = path.resolve("/tmp/.npm/_npx/63c3/node_modules/.bin/clawdbot"); const indexPath = path.resolve("/tmp/.npm/_npx/63c3/node_modules/clawdbot/dist/index.js"); diff --git a/src/daemon/program-args.ts b/src/daemon/program-args.ts index e606ae370..a9e0f3735 100644 --- a/src/daemon/program-args.ts +++ b/src/daemon/program-args.ts @@ -27,6 +27,20 @@ async function resolveCliEntrypointPathForService(): Promise { const looksLikeDist = /[/\\]dist[/\\].+\.(cjs|js|mjs)$/.test(resolvedPath); if (looksLikeDist) { await fs.access(resolvedPath); + // Prefer the original (possibly symlinked) path over the resolved realpath. + // This keeps LaunchAgent/systemd paths stable across package version updates, + // since symlinks like node_modules/clawdbot -> .pnpm/clawdbot@X.Y.Z/... + // are automatically updated by pnpm, while the resolved path contains + // version-specific directories that break after updates. + const normalizedLooksLikeDist = /[/\\]dist[/\\].+\.(cjs|js|mjs)$/.test(normalized); + if (normalizedLooksLikeDist && normalized !== resolvedPath) { + try { + await fs.access(normalized); + return normalized; + } catch { + // Fall through to return resolvedPath + } + } return resolvedPath; }