From ff30cef8a43a2997f6e259ae3749b23ba013eca0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 19:16:41 +0000 Subject: [PATCH] fix: expand linux service PATH handling --- CHANGELOG.md | 1 + docs/start/faq.md | 1 + src/daemon/service-audit.test.ts | 21 +++++++++++++++++ src/daemon/service-audit.ts | 11 ++++++--- src/daemon/service-env.test.ts | 25 ++++++++++++++++++++ src/daemon/service-env.ts | 40 +++++++++++++++++++++++++++----- test/setup.ts | 6 ++++- test/test-env.ts | 4 ++++ vitest.config.ts | 1 - vitest.e2e.config.ts | 1 - 10 files changed, 99 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddc935494..4106f7827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.clawd.bot ### Fixes - TUI: forward unknown slash commands (for example, `/context`) to the Gateway. - TUI: include Gateway slash commands in autocomplete and `/help`. +- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla. - Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla. - Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467) - Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman. diff --git a/docs/start/faq.md b/docs/start/faq.md index 38defb953..a3efb2b0b 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -324,6 +324,7 @@ brew install ``` If you run Clawdbot via systemd, ensure the service PATH includes `/home/linuxbrew/.linuxbrew/bin` (or your brew prefix) so `brew`-installed tools resolve in non‑login shells. +Recent builds also prepend common user bin dirs on Linux systemd services (for example `~/.local/bin`, `~/.npm-global/bin`, `~/.local/share/pnpm`, `~/.bun/bin`) and honor `PNPM_HOME`, `NPM_CONFIG_PREFIX`, `BUN_INSTALL`, `VOLTA_HOME`, `ASDF_DATA_DIR`, `NVM_DIR`, and `FNM_DIR` when set. ### Can I switch between npm and git installs later? diff --git a/src/daemon/service-audit.test.ts b/src/daemon/service-audit.test.ts index 328c04a1a..e8e8d89ff 100644 --- a/src/daemon/service-audit.test.ts +++ b/src/daemon/service-audit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { auditGatewayServiceConfig, SERVICE_AUDIT_CODES } from "./service-audit.js"; +import { buildMinimalServicePath } from "./service-env.js"; describe("auditGatewayServiceConfig", () => { it("flags bun runtime", async () => { @@ -39,4 +40,24 @@ describe("auditGatewayServiceConfig", () => { audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayPathMissingDirs), ).toBe(true); }); + + it("accepts Linux minimal PATH with user directories", async () => { + const env = { HOME: "/home/testuser", PNPM_HOME: "/opt/pnpm" }; + const minimalPath = buildMinimalServicePath({ platform: "linux", env }); + const audit = await auditGatewayServiceConfig({ + env, + platform: "linux", + command: { + programArguments: ["/usr/bin/node", "gateway"], + environment: { PATH: minimalPath }, + }, + }); + + expect( + audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayPathNonMinimal), + ).toBe(false); + expect( + audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayPathMissingDirs), + ).toBe(false); + }); }); diff --git a/src/daemon/service-audit.ts b/src/daemon/service-audit.ts index bf8ae8be3..20ddd4ff2 100644 --- a/src/daemon/service-audit.ts +++ b/src/daemon/service-audit.ts @@ -6,7 +6,7 @@ import { isVersionManagedNodePath, resolveSystemNodePath, } from "./runtime-paths.js"; -import { getMinimalServicePathParts } from "./service-env.js"; +import { getMinimalServicePathPartsFromEnv } from "./service-env.js"; import { resolveSystemdUserUnitPath } from "./systemd.js"; export type GatewayServiceCommand = { @@ -206,6 +206,7 @@ function normalizePathEntry(entry: string, platform: NodeJS.Platform): string { function auditGatewayServicePath( command: GatewayServiceCommand, issues: ServiceConfigIssue[], + env: Record, platform: NodeJS.Platform, ) { if (platform === "win32") return; @@ -219,12 +220,13 @@ function auditGatewayServicePath( return; } - const expected = getMinimalServicePathParts({ platform }); + const expected = getMinimalServicePathPartsFromEnv({ platform, env }); const parts = servicePath .split(getPathModule(platform).delimiter) .map((entry) => entry.trim()) .filter(Boolean); const normalizedParts = parts.map((entry) => normalizePathEntry(entry, platform)); + const normalizedExpected = new Set(expected.map((entry) => normalizePathEntry(entry, platform))); const missing = expected.filter((entry) => { const normalized = normalizePathEntry(entry, platform); return !normalizedParts.includes(normalized); @@ -239,6 +241,9 @@ function auditGatewayServicePath( const nonMinimal = parts.filter((entry) => { const normalized = normalizePathEntry(entry, platform); + if (normalizedExpected.has(normalized)) { + return false; + } return ( normalized.includes("/.nvm/") || normalized.includes("/.fnm/") || @@ -315,7 +320,7 @@ export async function auditGatewayServiceConfig(params: { const platform = params.platform ?? process.platform; auditGatewayCommand(params.command?.programArguments, issues); - auditGatewayServicePath(params.command, issues, platform); + auditGatewayServicePath(params.command, issues, params.env, platform); await auditGatewayRuntime(params.env, params.command, issues, platform); if (platform === "linux") { diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index cdc16cb65..b87ab2ece 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -5,6 +5,7 @@ import { buildNodeServiceEnvironment, buildServiceEnvironment, getMinimalServicePathParts, + getMinimalServicePathPartsFromEnv, } from "./service-env.js"; describe("getMinimalServicePathParts - Linux user directories", () => { @@ -70,6 +71,30 @@ describe("getMinimalServicePathParts - Linux user directories", () => { expect(extraDirIndex).toBeLessThan(userDirIndex); }); + it("includes env-configured bin roots when HOME is set on Linux", () => { + const result = getMinimalServicePathPartsFromEnv({ + platform: "linux", + env: { + HOME: "/home/testuser", + PNPM_HOME: "/opt/pnpm", + NPM_CONFIG_PREFIX: "/opt/npm", + BUN_INSTALL: "/opt/bun", + VOLTA_HOME: "/opt/volta", + ASDF_DATA_DIR: "/opt/asdf", + NVM_DIR: "/opt/nvm", + FNM_DIR: "/opt/fnm", + }, + }); + + expect(result).toContain("/opt/pnpm"); + expect(result).toContain("/opt/npm/bin"); + expect(result).toContain("/opt/bun/bin"); + expect(result).toContain("/opt/volta/bin"); + expect(result).toContain("/opt/asdf/shims"); + expect(result).toContain("/opt/nvm/current/bin"); + expect(result).toContain("/opt/fnm/current/bin"); + }); + it("does not include Linux user directories on macOS", () => { const result = getMinimalServicePathParts({ platform: "darwin", diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index a6d184e67..8c447c273 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -18,6 +18,7 @@ export type MinimalServicePathOptions = { platform?: NodeJS.Platform; extraDirs?: string[]; home?: string; + env?: Record; }; type BuildServicePathOptions = MinimalServicePathOptions & { @@ -38,11 +39,31 @@ function resolveSystemPathDirs(platform: NodeJS.Platform): string[] { * Resolve common user bin directories for Linux. * These are paths where npm global installs and node version managers typically place binaries. */ -export function resolveLinuxUserBinDirs(home: string | undefined): string[] { +export function resolveLinuxUserBinDirs( + home: string | undefined, + env?: Record, +): string[] { if (!home) return []; const dirs: string[] = []; + const add = (dir: string | undefined) => { + if (dir) dirs.push(dir); + }; + const appendSubdir = (base: string | undefined, subdir: string) => { + if (!base) return undefined; + return base.endsWith(`/${subdir}`) ? base : path.posix.join(base, subdir); + }; + + // Env-configured bin roots (override defaults when present). + add(env?.PNPM_HOME); + add(appendSubdir(env?.NPM_CONFIG_PREFIX, "bin")); + add(appendSubdir(env?.BUN_INSTALL, "bin")); + add(appendSubdir(env?.VOLTA_HOME, "bin")); + add(appendSubdir(env?.ASDF_DATA_DIR, "shims")); + add(appendSubdir(env?.NVM_DIR, "current/bin")); + add(appendSubdir(env?.FNM_DIR, "current/bin")); + // Common user bin directories dirs.push(`${home}/.local/bin`); // XDG standard, pip, etc. dirs.push(`${home}/.npm-global/bin`); // npm custom prefix (recommended for non-root) @@ -68,7 +89,8 @@ export function getMinimalServicePathParts(options: MinimalServicePathOptions = const systemDirs = resolveSystemPathDirs(platform); // Add Linux user bin directories (npm global, nvm, fnm, volta, etc.) - const linuxUserDirs = platform === "linux" ? resolveLinuxUserBinDirs(options.home) : []; + const linuxUserDirs = + platform === "linux" ? resolveLinuxUserBinDirs(options.home, options.env) : []; const add = (dir: string) => { if (!dir) return; @@ -83,6 +105,15 @@ export function getMinimalServicePathParts(options: MinimalServicePathOptions = return parts; } +export function getMinimalServicePathPartsFromEnv(options: BuildServicePathOptions = {}): string[] { + const env = options.env ?? process.env; + return getMinimalServicePathParts({ + ...options, + home: options.home ?? env.HOME, + env, + }); +} + export function buildMinimalServicePath(options: BuildServicePathOptions = {}): string { const env = options.env ?? process.env; const platform = options.platform ?? process.platform; @@ -90,10 +121,7 @@ export function buildMinimalServicePath(options: BuildServicePathOptions = {}): return env.PATH ?? ""; } - return getMinimalServicePathParts({ - ...options, - home: options.home ?? env.HOME, - }).join(path.delimiter); + return getMinimalServicePathPartsFromEnv({ ...options, env }).join(path.delimiter); } export function buildServiceEnvironment(params: { diff --git a/test/setup.ts b/test/setup.ts index 971fa4731..02cd85ef1 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, vi } from "vitest"; +import { afterAll, afterEach, beforeEach, vi } from "vitest"; import type { ChannelId, @@ -9,6 +9,10 @@ import type { ClawdbotConfig } from "../src/config/config.js"; import type { OutboundSendDeps } from "../src/infra/outbound/deliver.js"; import { setActivePluginRegistry } from "../src/plugins/runtime.js"; import { createTestRegistry } from "../src/test-utils/channel-plugins.js"; +import { withIsolatedTestHome } from "./test-env"; + +const testEnv = withIsolatedTestHome(); +afterAll(() => testEnv.cleanup()); const pickSendFn = (id: ChannelId, deps?: OutboundSendDeps) => { switch (id) { case "discord": diff --git a/test/test-env.ts b/test/test-env.ts index 815fe93d7..838713c52 100644 --- a/test/test-env.ts +++ b/test/test-env.ts @@ -130,3 +130,7 @@ export function installTestEnv(): { cleanup: () => void; tempHome: string } { return { cleanup, tempHome }; } + +export function withIsolatedTestHome(): { cleanup: () => void; tempHome: string } { + return installTestEnv(); +} diff --git a/vitest.config.ts b/vitest.config.ts index 8a783236c..210c4092b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,7 +26,6 @@ export default defineConfig({ "test/format-error.test.ts", ], setupFiles: ["test/setup.ts"], - globalSetup: ["test/global-setup.ts"], exclude: [ "dist/**", "apps/macos/**", diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index a33d324bd..ff6d8e94e 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -11,7 +11,6 @@ export default defineConfig({ maxWorkers: e2eWorkers, include: ["test/**/*.e2e.test.ts", "src/**/*.e2e.test.ts"], setupFiles: ["test/setup.ts"], - globalSetup: ["test/global-setup.ts"], exclude: [ "dist/**", "apps/macos/**",