From 85ce75c005a256a977f0a5bb831c16aa1433bfc5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 12:02:40 +0100 Subject: [PATCH] fix(daemon): canonicalize macOS service PATH --- CHANGELOG.md | 1 + docs/gateway/doctor.md | 2 +- src/commands/daemon-install-helpers.test.ts | 34 ++++++ src/commands/daemon-install-helpers.ts | 4 +- src/daemon/service-audit.test.ts | 12 +- src/daemon/service-env.test.ts | 116 +++++++++++--------- src/daemon/service-env.ts | 13 ++- 7 files changed, 111 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f48718b146..464ac74ec95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Models CLI: restore `openclaw models list --provider ` catalog and registry fallback rows for unconfigured providers, so provider-specific verification commands no longer report "No models found." Fixes #75517; supersedes #75615. Thanks @lotsoftick and @koshaji. +- Gateway/macOS: write LaunchAgent services with a canonical system PATH and stop preserving old plist PATH entries, so Volta, asdf, fnm, and pnpm shell paths no longer affect gateway child-process Node resolution. Fixes #75233; supersedes #75246. Thanks @nphyde2. - Slack/hooks: preserve bot alert attachment text in message-received hook content when command text is blank. Fixes #76035; refs #76036. Thanks @amsminn. - Control UI/chat: show inline feedback when local slash-command dispatch is unavailable or fails unexpectedly instead of clearing the composer silently. Fixes #52105. Thanks @MooreQiao. - Memory/markdown: replace CRLF managed blocks in place and collapse duplicate marker blocks without rewriting unmanaged markdown, so Dreaming and Memory Wiki files self-heal from repeated generated sections. Fixes #75491; supersedes #75495, #75810, and #76008. Thanks @asaenokkostya-coder, @ottodeng, @everettjf, and @lrg913427-dot. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 32265ca6fa7..b884aae19fa 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -473,7 +473,7 @@ That stages grounded durable candidates into the short-term dreaming store while Doctor warns when the gateway service runs on Bun or a version-managed Node path (`nvm`, `fnm`, `volta`, `asdf`, etc.). WhatsApp + Telegram channels require Node, and version-manager paths can break after upgrades because the service does not load your shell init. Doctor offers to migrate to a system Node install when available (Homebrew/apt/choco). - Newly installed or repaired services keep explicit environment roots (`NVM_DIR`, `FNM_DIR`, `VOLTA_HOME`, `ASDF_DATA_DIR`, `BUN_INSTALL`, `PNPM_HOME`) and stable user-bin directories, but guessed version-manager fallback directories are only written to the service PATH when those directories exist on disk. The audit accepts existing stable user-bin directories and explicit environment roots; it does not warn that missing, unconfigured `$HOME/.npm-global/bin`, `$HOME/bin`, or `$HOME/.nix-profile/bin` entries are required. + Newly installed or repaired macOS LaunchAgents use a canonical system PATH (`/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`) instead of copying the interactive shell PATH, so Volta, asdf, fnm, pnpm, and other version-manager directories do not change which Node child processes resolve. Linux services still keep explicit environment roots (`NVM_DIR`, `FNM_DIR`, `VOLTA_HOME`, `ASDF_DATA_DIR`, `BUN_INSTALL`, `PNPM_HOME`) and stable user-bin directories, but guessed version-manager fallback directories are only written to the service PATH when those directories exist on disk. diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index 91fe277da96..3a27c91292e 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -555,6 +555,7 @@ describe("buildGatewayInstallPlan — dotenv merge", () => { env: { HOME: tmpDir }, port: 3000, runtime: "node", + platform: "linux", existingEnvironment: { PATH: [ ".", @@ -647,6 +648,7 @@ describe("buildGatewayInstallPlan — dotenv merge", () => { env: { HOME: tmpDir }, port: 3000, runtime: "node", + platform: "linux", existingEnvironment: { PATH: "/opt/safe/bin:/opt/safe/missing-bin:/custom/go/bin:/usr/bin", }, @@ -673,6 +675,7 @@ describe("buildGatewayInstallPlan — dotenv merge", () => { env: { HOME: cwd }, port: 3000, runtime: "node", + platform: "linux", existingEnvironment: { PATH: `${cwd}/evil-bin:/custom/go/bin:/usr/bin`, }, @@ -694,6 +697,7 @@ describe("buildGatewayInstallPlan — dotenv merge", () => { env: { HOME: tmpDir }, port: 3000, runtime: "node", + platform: "linux", existingEnvironment: { PATH: "/custom/go/bin:/usr/bin", GOBIN: "/Users/test/.local/gopath/bin", @@ -710,6 +714,36 @@ describe("buildGatewayInstallPlan — dotenv merge", () => { expect(plan.environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS).toBeUndefined(); }); + it("does not preserve existing PATH entries for macOS LaunchAgents", async () => { + mockNodeGatewayPlanFixture({ + serviceEnvironment: { + HOME: "/from-service", + OPENCLAW_PORT: "3000", + PATH: "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", + TMPDIR: "/tmp", + }, + }); + + const plan = await buildGatewayInstallPlan({ + env: { HOME: tmpDir }, + port: 3000, + runtime: "node", + platform: "darwin", + existingEnvironment: { + PATH: [ + "/Users/test/.volta/bin", + "/Users/test/.asdf/shims", + "/Users/test/Library/Application Support/fnm/aliases/default/bin", + "/Users/test/Library/pnpm", + "/custom/go/bin", + "/usr/bin", + ].join(path.delimiter), + }, + }); + + expect(plan.environment.PATH).toBe("/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"); + }); + it("drops legacy inline env values when the key is now managed by .env", async () => { await writeStateDirDotEnv("TAVILY_API_KEY=fresh-dotenv-value\n", { stateDir: path.join(tmpDir, ".openclaw"), diff --git a/src/commands/daemon-install-helpers.ts b/src/commands/daemon-install-helpers.ts index 30436c5ab00..256cde6e36e 100644 --- a/src/commands/daemon-install-helpers.ts +++ b/src/commands/daemon-install-helpers.ts @@ -262,7 +262,9 @@ function mergeServicePath( } }; addPath(nextPath); - addPath(existingPath, { preserve: true }); + if (platform !== "darwin") { + addPath(existingPath, { preserve: true }); + } return segments.length > 0 ? segments.join(path.delimiter) : undefined; } diff --git a/src/daemon/service-audit.test.ts b/src/daemon/service-audit.test.ts index 871fbdf5209..e9297cb05d4 100644 --- a/src/daemon/service-audit.test.ts +++ b/src/daemon/service-audit.test.ts @@ -123,18 +123,10 @@ describe("auditGatewayServiceConfig", () => { ).toBe(false); }); - it("does not require missing unconfigured user-bin defaults in gateway service PATH", async () => { + it("accepts canonical macOS gateway service PATH without user-bin defaults", async () => { const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-service-audit-home-")); try { - const localBin = path.join(home, ".local/bin"); - await fs.mkdir(localBin, { recursive: true }); - const servicePath = [ - localBin, - "/opt/homebrew/bin", - "/usr/local/bin", - "/usr/bin", - "/bin", - ].join(":"); + const servicePath = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"; const audit = await auditGatewayServiceConfig({ env: { HOME: home }, diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index 003221df962..b55ce4e6f4f 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -111,36 +111,21 @@ describe("getMinimalServicePathParts - Linux user directories", () => { expect(result).toContain("/opt/fnm/current/bin"); }); - it("includes version manager directories on macOS when HOME is set", () => { + it("uses only canonical system directories on macOS by default", () => { const result = getMinimalServicePathParts({ platform: "darwin", home: "/Users/testuser", existsSync: allExist, }); - // Should include common user bin directories - expect(result).toContain("/Users/testuser/.local/bin"); - expect(result).toContain("/Users/testuser/.npm-global/bin"); - expect(result).toContain("/Users/testuser/bin"); - - // Should include version manager paths (macOS specific) - // Note: nvm has no stable default path, relies on user's shell config - expect(result).toContain("/Users/testuser/Library/Application Support/fnm/aliases/default/bin"); // fnm default on macOS - expect(result).toContain("/Users/testuser/.fnm/aliases/default/bin"); // fnm if customized to ~/.fnm - expect(result).toContain("/Users/testuser/.volta/bin"); - expect(result).toContain("/Users/testuser/.asdf/shims"); - expect(result).toContain("/Users/testuser/Library/pnpm"); // pnpm default on macOS - expect(result).toContain("/Users/testuser/.local/share/pnpm"); // pnpm XDG fallback - expect(result).toContain("/Users/testuser/.bun/bin"); - - // Should also include macOS system directories - expect(result).toContain("/opt/homebrew/bin"); - expect(result).toContain("/usr/local/bin"); + expect(result).toEqual(["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]); + expect(result.some((entry) => entry.startsWith("/Users/testuser/"))).toBe(false); }); - it("includes env-configured version manager dirs on macOS", () => { + it("can include env-configured version manager dirs on macOS when requested", () => { const result = getMinimalServicePathPartsFromEnv({ platform: "darwin", + includeUserDirs: true, env: { HOME: "/Users/testuser", FNM_DIR: "/Users/testuser/Library/Application Support/fnm", @@ -158,22 +143,18 @@ describe("getMinimalServicePathParts - Linux user directories", () => { expect(result).toContain("/Users/testuser/Library/pnpm"); }); - it("places version manager dirs before system dirs on macOS", () => { + it("does not let version manager dirs precede system dirs on macOS by default", () => { const result = getMinimalServicePathParts({ platform: "darwin", home: "/Users/testuser", existsSync: allExist, }); - // fnm on macOS defaults to ~/Library/Application Support/fnm - const fnmIndex = result.indexOf( - "/Users/testuser/Library/Application Support/fnm/aliases/default/bin", - ); - const homebrewIndex = result.indexOf("/opt/homebrew/bin"); + const fnmIndex = result.indexOf("/Users/testuser/.fnm/aliases/default/bin"); + const systemIndex = result.indexOf("/usr/local/bin"); - expect(fnmIndex).toBeGreaterThan(-1); - expect(homebrewIndex).toBeGreaterThan(-1); - expect(fnmIndex).toBeLessThan(homebrewIndex); + expect(fnmIndex).toBe(-1); + expect(systemIndex).toBe(0); }); it("does not include Linux user directories on Windows", () => { @@ -209,17 +190,18 @@ describe("getMinimalServicePathParts - Linux user directories", () => { expect(result).not.toContain("/home/testuser/.local/share/pnpm"); }); - it("omits hard-coded version-manager fallbacks on macOS when missing", () => { + it("omits all user PATH fallbacks on macOS even when HOME is set", () => { const result = getMinimalServicePathParts({ platform: "darwin", home: "/Users/testuser", existsSync: noneExist, }); - expect(result).toContain("/Users/testuser/.local/bin"); - expect(result).toContain("/Users/testuser/.npm-global/bin"); - expect(result).toContain("/Users/testuser/bin"); - expect(result).toContain("/Users/testuser/.nix-profile/bin"); + expect(result).toEqual(["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]); + expect(result).not.toContain("/Users/testuser/.local/bin"); + expect(result).not.toContain("/Users/testuser/.npm-global/bin"); + expect(result).not.toContain("/Users/testuser/bin"); + expect(result).not.toContain("/Users/testuser/.nix-profile/bin"); expect(result).not.toContain("/Users/testuser/.volta/bin"); expect(result).not.toContain("/Users/testuser/.asdf/shims"); expect(result).not.toContain("/Users/testuser/.bun/bin"); @@ -233,16 +215,16 @@ describe("getMinimalServicePathParts - Linux user directories", () => { it("can omit missing stable user-bin defaults for service PATH audits", () => { const result = getMinimalServicePathPartsFromEnv({ - platform: "darwin", - env: { HOME: "/Users/testuser" }, - existsSync: (candidate) => candidate === "/Users/testuser/.local/bin", + platform: "linux", + env: { HOME: "/home/testuser" }, + existsSync: (candidate) => candidate === "/home/testuser/.local/bin", includeMissingUserBinDefaults: false, }); - expect(result).toContain("/Users/testuser/.local/bin"); - expect(result).not.toContain("/Users/testuser/.npm-global/bin"); - expect(result).not.toContain("/Users/testuser/bin"); - expect(result).not.toContain("/Users/testuser/.nix-profile/bin"); + expect(result).toContain("/home/testuser/.local/bin"); + expect(result).not.toContain("/home/testuser/.npm-global/bin"); + expect(result).not.toContain("/home/testuser/bin"); + expect(result).not.toContain("/home/testuser/.nix-profile/bin"); }); it("keeps env-configured roots when fallback directories are missing", () => { @@ -371,14 +353,15 @@ describe("getMinimalServicePathParts - Nix Home Manager", () => { expect(result).toContain("/home/testuser/.nix-profile/bin"); }); - it("falls back to default Nix profile when NIX_PROFILES is absent on macOS", () => { + it("omits the default Nix profile from macOS service PATH by default", () => { const result = getMinimalServicePathParts({ platform: "darwin", home: "/Users/testuser", existsSync: () => true, }); - expect(result).toContain("/Users/testuser/.nix-profile/bin"); + expect(result).not.toContain("/Users/testuser/.nix-profile/bin"); + expect(result).toEqual(["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]); }); it("places rightmost NIX_PROFILES entry before leftmost on Linux", () => { @@ -398,7 +381,7 @@ describe("getMinimalServicePathParts - Nix Home Manager", () => { expect(userIdx).toBeLessThan(defaultIdx); }); - it("places rightmost NIX_PROFILES entry before leftmost on macOS", () => { + it("ignores NIX_PROFILES on macOS service PATH by default", () => { const result = getMinimalServicePathPartsFromEnv({ platform: "darwin", env: { @@ -410,9 +393,9 @@ describe("getMinimalServicePathParts - Nix Home Manager", () => { const userIdx = result.indexOf("/Users/testuser/.nix-profile/bin"); const defaultIdx = result.indexOf("/nix/var/nix/profiles/default/bin"); - expect(userIdx).toBeGreaterThan(-1); - expect(defaultIdx).toBeGreaterThan(-1); - expect(userIdx).toBeLessThan(defaultIdx); + expect(userIdx).toBe(-1); + expect(defaultIdx).toBe(-1); + expect(result).toEqual(["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]); }); it("includes single Nix profile from NIX_PROFILES on Linux", () => { @@ -428,9 +411,10 @@ describe("getMinimalServicePathParts - Nix Home Manager", () => { expect(result).toContain("/nix/var/nix/profiles/per-user/testuser/profile/bin"); }); - it("includes single Nix profile from NIX_PROFILES on macOS", () => { + it("can include single Nix profile from NIX_PROFILES on macOS when requested", () => { const result = getMinimalServicePathPartsFromEnv({ platform: "darwin", + includeUserDirs: true, env: { HOME: "/Users/testuser", NIX_PROFILES: "/nix/var/nix/profiles/per-user/testuser/profile", @@ -467,15 +451,12 @@ describe("buildMinimalServicePath", () => { const splitPath = (value: string, platform: NodeJS.Platform) => value.split(platform === "win32" ? path.win32.delimiter : path.posix.delimiter); - it("includes Homebrew + system dirs on macOS", () => { + it("uses canonical launchd system dirs on macOS", () => { const result = buildMinimalServicePath({ platform: "darwin", }); const parts = splitPath(result, "darwin"); - expect(parts).toContain("/opt/homebrew/bin"); - expect(parts).toContain("/usr/local/bin"); - expect(parts).toContain("/usr/bin"); - expect(parts).toContain("/bin"); + expect(parts).toEqual(["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]); }); it("returns PATH as-is on Windows", () => { @@ -622,6 +603,22 @@ describe("buildServiceEnvironment", () => { expect(env.TMPDIR).toBe(path.join("/Users/user", ".openclaw", "tmp")); }); + it("uses a canonical system PATH for macOS LaunchAgents", () => { + const env = buildServiceEnvironment({ + env: { + HOME: "/Users/user", + FNM_DIR: "/Users/user/Library/Application Support/fnm", + PNPM_HOME: "/Users/user/Library/pnpm", + VOLTA_HOME: "/Users/user/.volta", + ASDF_DATA_DIR: "/Users/user/.asdf", + }, + port: 18789, + platform: "darwin", + }); + + expect(env.PATH).toBe("/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"); + }); + it("falls back to os.tmpdir when TMPDIR is not set on Linux", () => { const env = buildServiceEnvironment({ env: { HOME: "/home/user" }, @@ -701,6 +698,19 @@ describe("buildServiceEnvironment", () => { "/home/user/.nvm/versions/node/v22.22.0/bin", ); }); + + it("prepends explicit runtime directories to macOS LaunchAgent PATH", () => { + const env = buildServiceEnvironment({ + env: { HOME: "/Users/user", VOLTA_HOME: "/Users/user/.volta" }, + port: 18789, + platform: "darwin", + extraPathDirs: ["/opt/homebrew/Cellar/node/22.14.0/bin"], + }); + + expect(env.PATH).toBe( + "/opt/homebrew/Cellar/node/22.14.0/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", + ); + }); }); describe("buildNodeServiceEnvironment", () => { diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index a5a0705819f..ebc4d422d3e 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -28,6 +28,7 @@ export { isNodeVersionManagerRuntime, resolveLinuxSystemCaBundle }; type MinimalServicePathOptions = { platform?: NodeJS.Platform; extraDirs?: string[]; + includeUserDirs?: boolean; home?: string; cwd?: string; env?: Record; @@ -221,7 +222,7 @@ function addNixProfileBinDirs( function resolveSystemPathDirs(platform: NodeJS.Platform): string[] { if (platform === "darwin") { - return ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]; + return ["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]; } if (platform === "linux") { return ["/usr/local/bin", "/usr/bin", "/bin"]; @@ -330,15 +331,16 @@ export function getMinimalServicePathParts(options: MinimalServicePathOptions = const parts: string[] = []; const extraDirs = options.extraDirs ?? []; const systemDirs = resolveSystemPathDirs(platform); + const includeUserDirs = options.includeUserDirs ?? platform !== "darwin"; - // Add user bin directories for version managers (npm global, nvm, fnm, volta, etc.) const existsSync = options.existsSync ?? fs.existsSync; - const userDirs = - platform === "linux" + const userDirs = includeUserDirs + ? platform === "linux" ? resolveLinuxUserBinDirs(options.home, options.env, existsSync, options) : platform === "darwin" ? resolveDarwinUserBinDirs(options.home, options.env, existsSync, options) - : []; + : [] + : []; const add = (dir: string) => { if (!dir) { @@ -352,7 +354,6 @@ export function getMinimalServicePathParts(options: MinimalServicePathOptions = for (const dir of extraDirs) { add(dir); } - // User dirs first so user-installed binaries take precedence for (const dir of userDirs) { add(dir); }