diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d93605042c..cb65d93406b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai ### Fixes - CLI/Ollama: run local `infer model run` through the lean provider completion path and skip global model discovery for one-shot local probes, so Ollama smoke tests no longer pay full chat-agent/tool startup cost or hang before the native `/api/chat` request. Fixes #72851. Thanks @TotalRes2020. +- Daemon/service: only emit hard-coded version-manager paths such as `~/.volta/bin`, `~/.asdf/shims`, `~/.bun/bin`, and fnm/pnpm fallbacks into gateway and node service PATHs when the directories exist, so `openclaw doctor` no longer flags `gateway.path.non-minimal` against a PATH the daemon just wrote. Env-driven roots and stable user-bin dirs remain unconditional. Fixes #71944; carries forward #71964. Thanks @Sanjays2402. - Channels/commands: make generated `/dock-*` commands switch the active session reply route through `session.identityLinks` instead of falling through to normal chat. Fixes #69206; carries forward #73033. Thanks @clawbones and @michaelatamuk. - Providers/Cloudflare AI Gateway: strip assistant prefill turns from Anthropic Messages payloads when thinking is enabled, so Claude requests through Cloudflare AI Gateway no longer fail Anthropic conversation-ending validation. Fixes #72905; carries forward #73005. Thanks @AaronFaby and @sahilsatralkar. - Gateway/startup: keep primary-model startup prewarm on scoped metadata preparation, let native approval bootstraps retry outside channel startup, and skip the global hook runner when no `gateway_start` hook is registered, so clean post-ready sidecar work stays off the critical path. Refs #72846. Thanks @RayWoo, @livekm0309, and @mrz1836. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 93983dd7ad2..d39459606a7 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -455,6 +455,9 @@ 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. This keeps the generated supervisor PATH aligned with the same minimal-PATH audit doctor runs later. + Doctor persists any config changes and stamps wizard metadata to record the doctor run. diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index ffeaa73b09e..67f2ef28d2e 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -13,10 +13,14 @@ import { } from "./service-env.js"; describe("getMinimalServicePathParts - Linux user directories", () => { + const allExist = (): boolean => true; + const noneExist = (): boolean => false; + it("includes user bin directories when HOME is set on Linux", () => { const result = getMinimalServicePathParts({ platform: "linux", home: "/home/testuser", + existsSync: allExist, }); // Should include all common user bin directories @@ -53,6 +57,7 @@ describe("getMinimalServicePathParts - Linux user directories", () => { const result = getMinimalServicePathParts({ platform: "linux", home: "/home/testuser", + existsSync: allExist, }); const userDirIndex = result.indexOf("/home/testuser/.local/bin"); @@ -68,6 +73,7 @@ describe("getMinimalServicePathParts - Linux user directories", () => { platform: "linux", home: "/home/testuser", extraDirs: ["/custom/bin"], + existsSync: allExist, }); const extraDirIndex = result.indexOf("/custom/bin"); @@ -91,6 +97,7 @@ describe("getMinimalServicePathParts - Linux user directories", () => { NVM_DIR: "/opt/nvm", FNM_DIR: "/opt/fnm", }, + existsSync: allExist, }); expect(result).toContain("/opt/pnpm"); @@ -107,6 +114,7 @@ describe("getMinimalServicePathParts - Linux user directories", () => { const result = getMinimalServicePathParts({ platform: "darwin", home: "/Users/testuser", + existsSync: allExist, }); // Should include common user bin directories @@ -138,6 +146,7 @@ describe("getMinimalServicePathParts - Linux user directories", () => { NVM_DIR: "/Users/testuser/.nvm", PNPM_HOME: "/Users/testuser/Library/pnpm", }, + existsSync: allExist, }); // fnm uses aliases/default/bin (not current) @@ -152,6 +161,7 @@ describe("getMinimalServicePathParts - Linux user directories", () => { const result = getMinimalServicePathParts({ platform: "darwin", home: "/Users/testuser", + existsSync: allExist, }); // fnm on macOS defaults to ~/Library/Application Support/fnm @@ -169,11 +179,97 @@ describe("getMinimalServicePathParts - Linux user directories", () => { const result = getMinimalServicePathParts({ platform: "win32", home: "C:\\Users\\testuser", + existsSync: allExist, }); // Windows returns empty array (uses existing PATH) expect(result).toEqual([]); }); + + it("omits hard-coded version-manager fallbacks on Linux when missing", () => { + const result = getMinimalServicePathParts({ + platform: "linux", + home: "/home/testuser", + existsSync: noneExist, + }); + + expect(result).toContain("/home/testuser/.local/bin"); + expect(result).toContain("/home/testuser/.npm-global/bin"); + expect(result).toContain("/home/testuser/bin"); + expect(result).toContain("/home/testuser/.nix-profile/bin"); + expect(result).not.toContain("/home/testuser/.volta/bin"); + expect(result).not.toContain("/home/testuser/.asdf/shims"); + expect(result).not.toContain("/home/testuser/.bun/bin"); + expect(result).not.toContain("/home/testuser/.nvm/current/bin"); + expect(result).not.toContain("/home/testuser/.local/share/fnm/aliases/default/bin"); + expect(result).not.toContain("/home/testuser/.local/share/fnm/current/bin"); + expect(result).not.toContain("/home/testuser/.fnm/aliases/default/bin"); + expect(result).not.toContain("/home/testuser/.fnm/current/bin"); + expect(result).not.toContain("/home/testuser/.local/share/pnpm"); + }); + + it("omits hard-coded version-manager fallbacks on macOS when missing", () => { + 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).not.toContain("/Users/testuser/.volta/bin"); + expect(result).not.toContain("/Users/testuser/.asdf/shims"); + expect(result).not.toContain("/Users/testuser/.bun/bin"); + expect(result).not.toContain( + "/Users/testuser/Library/Application Support/fnm/aliases/default/bin", + ); + expect(result).not.toContain("/Users/testuser/.fnm/aliases/default/bin"); + expect(result).not.toContain("/Users/testuser/Library/pnpm"); + expect(result).not.toContain("/Users/testuser/.local/share/pnpm"); + }); + + it("keeps env-configured roots when fallback directories are missing", () => { + const result = getMinimalServicePathPartsFromEnv({ + platform: "linux", + env: { + HOME: "/home/testuser", + PNPM_HOME: "/opt/pnpm", + VOLTA_HOME: "/opt/volta", + BUN_INSTALL: "/opt/bun", + ASDF_DATA_DIR: "/opt/asdf", + NVM_DIR: "/opt/nvm", + FNM_DIR: "/opt/fnm", + }, + existsSync: noneExist, + }); + + expect(result).toContain("/opt/pnpm"); + expect(result).toContain("/opt/volta/bin"); + expect(result).toContain("/opt/bun/bin"); + expect(result).toContain("/opt/asdf/shims"); + expect(result).toContain("/opt/nvm/current/bin"); + expect(result).toContain("/opt/fnm/aliases/default/bin"); + expect(result).toContain("/opt/fnm/current/bin"); + }); + + it("emits only existing hard-coded version-manager fallbacks", () => { + const exists = (candidate: string) => + candidate === "/home/testuser/.volta/bin" || + candidate === "/home/testuser/.local/share/fnm/aliases/default/bin"; + const result = getMinimalServicePathParts({ + platform: "linux", + home: "/home/testuser", + existsSync: exists, + }); + + expect(result).toContain("/home/testuser/.volta/bin"); + expect(result).toContain("/home/testuser/.local/share/fnm/aliases/default/bin"); + expect(result).not.toContain("/home/testuser/.bun/bin"); + expect(result).not.toContain("/home/testuser/.asdf/shims"); + expect(result).not.toContain("/home/testuser/.fnm/aliases/default/bin"); + }); }); describe("getMinimalServicePathParts - Nix Home Manager", () => { @@ -181,6 +277,7 @@ describe("getMinimalServicePathParts - Nix Home Manager", () => { const result = getMinimalServicePathParts({ platform: "linux", home: "/home/testuser", + existsSync: () => true, }); expect(result).toContain("/home/testuser/.nix-profile/bin"); @@ -190,6 +287,7 @@ describe("getMinimalServicePathParts - Nix Home Manager", () => { const result = getMinimalServicePathParts({ platform: "darwin", home: "/Users/testuser", + existsSync: () => true, }); expect(result).toContain("/Users/testuser/.nix-profile/bin"); @@ -202,6 +300,7 @@ describe("getMinimalServicePathParts - Nix Home Manager", () => { HOME: "/home/testuser", NIX_PROFILES: "/nix/var/nix/profiles/default /home/testuser/.nix-profile", }, + existsSync: () => true, }); const userIdx = result.indexOf("/home/testuser/.nix-profile/bin"); @@ -218,6 +317,7 @@ describe("getMinimalServicePathParts - Nix Home Manager", () => { HOME: "/Users/testuser", NIX_PROFILES: "/nix/var/nix/profiles/default /Users/testuser/.nix-profile", }, + existsSync: () => true, }); const userIdx = result.indexOf("/Users/testuser/.nix-profile/bin"); @@ -234,6 +334,7 @@ describe("getMinimalServicePathParts - Nix Home Manager", () => { HOME: "/home/testuser", NIX_PROFILES: "/nix/var/nix/profiles/per-user/testuser/profile", }, + existsSync: () => true, }); expect(result).toContain("/nix/var/nix/profiles/per-user/testuser/profile/bin"); @@ -246,6 +347,7 @@ describe("getMinimalServicePathParts - Nix Home Manager", () => { HOME: "/Users/testuser", NIX_PROFILES: "/nix/var/nix/profiles/per-user/testuser/profile", }, + existsSync: () => true, }); expect(result).toContain("/nix/var/nix/profiles/per-user/testuser/profile/bin"); @@ -259,6 +361,7 @@ describe("getMinimalServicePathParts - Nix Home Manager", () => { NIX_PROFILES: "/nix/var/nix/profiles/default /nix/var/nix/profiles/per-user/testuser/custom /home/testuser/.nix-profile", }, + existsSync: () => true, }); const userIdx = result.indexOf("/home/testuser/.nix-profile/bin"); @@ -299,6 +402,7 @@ describe("buildMinimalServicePath", () => { const result = buildMinimalServicePath({ platform: "linux", env: { HOME: "/home/alice" }, + existsSync: () => true, }); const parts = splitPath(result, "linux"); @@ -332,6 +436,7 @@ describe("buildMinimalServicePath", () => { const result = buildMinimalServicePath({ platform: "linux", env: { HOME: "/home/bob" }, + existsSync: () => true, }); const parts = splitPath(result, "linux"); @@ -366,6 +471,7 @@ describe("buildMinimalServicePath", () => { platform: "linux", extraDirs: ["/home/alice/.nvm/versions/node/v22.22.0/bin"], env: { HOME: "/home/alice" }, + existsSync: () => true, }); const parts = splitPath(result, "linux"); diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index 29c86eff35b..0336be0024b 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { @@ -29,6 +30,7 @@ export type MinimalServicePathOptions = { extraDirs?: string[]; home?: string; env?: Record; + existsSync?: (candidate: string) => boolean; }; type BuildServicePathOptions = MinimalServicePathOptions & { @@ -68,13 +70,27 @@ function appendSubdir(base: string | undefined, subdir: string): string | undefi return base.endsWith(`/${subdir}`) ? base : path.posix.join(base, subdir); } -function addCommonUserBinDirs(dirs: string[], home: string): void { +function addExistingDir( + dirs: string[], + candidate: string, + existsSync: (candidate: string) => boolean, +): void { + if (existsSync(candidate)) { + dirs.push(candidate); + } +} + +function addCommonUserBinDirs( + dirs: string[], + home: string, + existsSync: (candidate: string) => boolean, +): void { dirs.push(`${home}/.local/bin`); dirs.push(`${home}/.npm-global/bin`); dirs.push(`${home}/bin`); - dirs.push(`${home}/.volta/bin`); - dirs.push(`${home}/.asdf/shims`); - dirs.push(`${home}/.bun/bin`); + addExistingDir(dirs, `${home}/.volta/bin`, existsSync); + addExistingDir(dirs, `${home}/.asdf/shims`, existsSync); + addExistingDir(dirs, `${home}/.bun/bin`, existsSync); } function addCommonEnvConfiguredBinDirs( @@ -126,6 +142,7 @@ function resolveSystemPathDirs(platform: NodeJS.Platform): string[] { export function resolveDarwinUserBinDirs( home: string | undefined, env?: Record, + existsSync: (candidate: string) => boolean = fs.existsSync, ): string[] { if (!home) { return []; @@ -145,7 +162,7 @@ export function resolveDarwinUserBinDirs( // pnpm: binary is directly in PNPM_HOME (not in bin subdirectory) // Common user bin directories - addCommonUserBinDirs(dirs, home); + addCommonUserBinDirs(dirs, home, existsSync); // Nix Home Manager (cross-platform) addNixProfileBinDirs(dirs, home, env); @@ -153,11 +170,11 @@ export function resolveDarwinUserBinDirs( // Node version managers - macOS specific paths // nvm: no stable default path, depends on user's shell configuration // fnm: macOS default is ~/Library/Application Support/fnm, not ~/.fnm - dirs.push(`${home}/Library/Application Support/fnm/aliases/default/bin`); // fnm default - dirs.push(`${home}/.fnm/aliases/default/bin`); // fnm if customized to ~/.fnm + addExistingDir(dirs, `${home}/Library/Application Support/fnm/aliases/default/bin`, existsSync); // fnm default + addExistingDir(dirs, `${home}/.fnm/aliases/default/bin`, existsSync); // fnm if customized to ~/.fnm // pnpm: macOS default is ~/Library/pnpm, not ~/.local/share/pnpm - dirs.push(`${home}/Library/pnpm`); // pnpm default - dirs.push(`${home}/.local/share/pnpm`); // pnpm XDG fallback + addExistingDir(dirs, `${home}/Library/pnpm`, existsSync); // pnpm default + addExistingDir(dirs, `${home}/.local/share/pnpm`, existsSync); // pnpm XDG fallback return dirs; } @@ -169,6 +186,7 @@ export function resolveDarwinUserBinDirs( export function resolveLinuxUserBinDirs( home: string | undefined, env?: Record, + existsSync: (candidate: string) => boolean = fs.existsSync, ): string[] { if (!home) { return []; @@ -183,18 +201,18 @@ export function resolveLinuxUserBinDirs( addNonEmptyDir(dirs, appendSubdir(env?.FNM_DIR, "current/bin")); // Common user bin directories - addCommonUserBinDirs(dirs, home); + addCommonUserBinDirs(dirs, home, existsSync); // Nix Home Manager (cross-platform) addNixProfileBinDirs(dirs, home, env); // Node version managers - dirs.push(`${home}/.nvm/current/bin`); // nvm with current symlink - dirs.push(`${home}/.local/share/fnm/aliases/default/bin`); // fnm default - dirs.push(`${home}/.local/share/fnm/current/bin`); // fnm legacy current symlink - dirs.push(`${home}/.fnm/aliases/default/bin`); // fnm if customized to ~/.fnm - dirs.push(`${home}/.fnm/current/bin`); // fnm legacy current symlink - dirs.push(`${home}/.local/share/pnpm`); // pnpm global bin + addExistingDir(dirs, `${home}/.nvm/current/bin`, existsSync); // nvm with current symlink + addExistingDir(dirs, `${home}/.local/share/fnm/aliases/default/bin`, existsSync); // fnm default + addExistingDir(dirs, `${home}/.local/share/fnm/current/bin`, existsSync); // fnm legacy current symlink + addExistingDir(dirs, `${home}/.fnm/aliases/default/bin`, existsSync); // fnm if customized to ~/.fnm + addExistingDir(dirs, `${home}/.fnm/current/bin`, existsSync); // fnm legacy current symlink + addExistingDir(dirs, `${home}/.local/share/pnpm`, existsSync); // pnpm global bin return dirs; } @@ -210,11 +228,12 @@ export function getMinimalServicePathParts(options: MinimalServicePathOptions = const systemDirs = resolveSystemPathDirs(platform); // Add user bin directories for version managers (npm global, nvm, fnm, volta, etc.) + const existsSync = options.existsSync ?? fs.existsSync; const userDirs = platform === "linux" - ? resolveLinuxUserBinDirs(options.home, options.env) + ? resolveLinuxUserBinDirs(options.home, options.env, existsSync) : platform === "darwin" - ? resolveDarwinUserBinDirs(options.home, options.env) + ? resolveDarwinUserBinDirs(options.home, options.env, existsSync) : []; const add = (dir: string) => {