From 375112b5b1c9e20c76f8f4299740df65c2b323a2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 19:05:08 +0000 Subject: [PATCH] fix: align linux service PATH + tests (#1512) (thanks @robbyczgw-cla) --- CHANGELOG.md | 1 + src/daemon/service-audit.test.ts | 21 +++++++++++++++++++++ src/daemon/service-audit.ts | 9 +++++++-- src/daemon/service-env.test.ts | 24 ++++++++++++++++++++++++ src/daemon/service-env.ts | 26 ++++++++++++++++++++++++-- test/setup.ts | 6 +++++- vitest.config.ts | 1 - vitest.e2e.config.ts | 1 - 8 files changed, 82 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 889c8ef67..9c947a072 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. - Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla. +- Gateway/Linux: include user + env-configured bin dirs in systemd PATH and align service audit checks. (#1512) Thanks @robbyczgw-cla. - Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467) ## 2026.1.22 diff --git a/src/daemon/service-audit.test.ts b/src/daemon/service-audit.test.ts index 328c04a1a..1bf716769 100644 --- a/src/daemon/service-audit.test.ts +++ b/src/daemon/service-audit.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { buildMinimalServicePath } from "./service-env.js"; import { auditGatewayServiceConfig, SERVICE_AUDIT_CODES } from "./service-audit.js"; describe("auditGatewayServiceConfig", () => { @@ -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..328be56a5 100644 --- a/src/daemon/service-audit.ts +++ b/src/daemon/service-audit.ts @@ -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 = getMinimalServicePathParts({ platform, home: env.HOME, 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..18038b35c 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -70,6 +70,30 @@ describe("getMinimalServicePathParts - Linux user directories", () => { expect(extraDirIndex).toBeLessThan(userDirIndex); }); + it("includes env-configured bin roots when HOME is set on Linux", () => { + const result = getMinimalServicePathParts({ + platform: "linux", + home: "/home/testuser", + env: { + 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..998b44876 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; diff --git a/test/setup.ts b/test/setup.ts index 971fa4731..c4d0c804b 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 { installTestEnv } from "./test-env"; + +const testEnv = installTestEnv(); +afterAll(() => testEnv.cleanup()); const pickSendFn = (id: ChannelId, deps?: OutboundSendDeps) => { switch (id) { case "discord": 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/**",