mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:40:44 +00:00
fix(daemon): canonicalize macOS service PATH
This commit is contained in:
@@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- Models CLI: restore `openclaw models list --provider <id>` 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.
|
||||
|
||||
@@ -473,7 +473,7 @@ That stages grounded durable candidates into the short-term dreaming store while
|
||||
<Accordion title="17. Gateway runtime best practices">
|
||||
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.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="18. Config write + wizard metadata">
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ export { isNodeVersionManagerRuntime, resolveLinuxSystemCaBundle };
|
||||
type MinimalServicePathOptions = {
|
||||
platform?: NodeJS.Platform;
|
||||
extraDirs?: string[];
|
||||
includeUserDirs?: boolean;
|
||||
home?: string;
|
||||
cwd?: string;
|
||||
env?: Record<string, string | undefined>;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user