Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-unix-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@paretools/shared": patch
---

Augment PATH with common Unix tool locations on macOS/Linux to fix ENOENT when MCP clients launch servers with stripped environment
96 changes: 96 additions & 0 deletions packages/shared/__tests__/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import {
_pickBestMatch,
_probeFallbackPaths,
_WIN32_FALLBACK_PATHS,
_augmentUnixPath,
_resetAugmentCache,
_unixExtraPaths,
} from "../src/runner.js";

// Helper script that prints its args as JSON — avoids issues with inline
Expand Down Expand Up @@ -678,3 +681,96 @@ describe("run – explicit shell: true", () => {
expect(result.stdout.trim()).toBe("hello");
});
});

// ---------------------------------------------------------------------------
// _augmentUnixPath — Unix PATH augmentation (#803)
// ---------------------------------------------------------------------------

describe("_augmentUnixPath", () => {
afterEach(() => {
_resetAugmentCache();
});

it("is a no-op on Windows", () => {
const env = { PATH: "/usr/bin" } as unknown as NodeJS.ProcessEnv;
_augmentUnixPath("win32", env);
expect(env.PATH).toBe("/usr/bin");
});

it("adds missing paths that exist on disk", () => {
// /usr/local/bin exists on virtually all Unix systems
const env = { PATH: "/usr/bin" } as unknown as NodeJS.ProcessEnv;
_augmentUnixPath("darwin", env);
// Should have added at least /usr/local/bin if it exists
if (existsSync("/usr/local/bin")) {
expect(env.PATH).toContain("/usr/local/bin");
}
// Original path should still be present
expect(env.PATH).toContain("/usr/bin");
});

it.skipIf(process.platform === "win32")("does not duplicate paths already present", () => {
const extraPaths = _unixExtraPaths();
const initialPath = [...extraPaths, "/usr/bin"].join(":");
const env = { PATH: initialPath } as unknown as NodeJS.ProcessEnv;
_augmentUnixPath("darwin", env);
// PATH should be unchanged — no duplicates
expect(env.PATH).toBe(initialPath);
});

it("caches the result and only computes once", () => {
const env = { PATH: "/usr/bin" } as unknown as NodeJS.ProcessEnv;
_augmentUnixPath("darwin", env);
// Mutate PATH and call again — should be a no-op due to caching
env.PATH = "/only/this";
_augmentUnixPath("darwin", env);
expect(env.PATH).toBe("/only/this"); // not re-augmented
});

it("resets cache with _resetAugmentCache", () => {
const env = { PATH: "/usr/bin" } as unknown as NodeJS.ProcessEnv;
_augmentUnixPath("darwin", env);
_resetAugmentCache();
// After reset, calling again should re-compute
_augmentUnixPath("darwin", env);
// Result should be the same (idempotent) but the function ran again
expect(env.PATH).toContain("/usr/bin");
});

it("handles empty PATH gracefully", () => {
const env = { PATH: "" } as unknown as NodeJS.ProcessEnv;
_augmentUnixPath("linux", env);
// Should not crash; PATH should contain whatever dirs exist
expect(typeof env.PATH).toBe("string");
});

it("handles undefined PATH gracefully", () => {
const env = {} as unknown as NodeJS.ProcessEnv;
_augmentUnixPath("linux", env);
expect(typeof env.PATH).toBe("string");
});

it("works on linux platform", () => {
const env = { PATH: "/usr/bin" } as unknown as NodeJS.ProcessEnv;
_augmentUnixPath("linux", env);
// Should not crash; on macOS test machines /usr/local/bin likely exists
expect(env.PATH).toContain("/usr/bin");
});
});

describe("_unixExtraPaths", () => {
it("returns an array of path strings", () => {
const paths = _unixExtraPaths();
expect(Array.isArray(paths)).toBe(true);
expect(paths.length).toBeGreaterThan(0);
for (const p of paths) {
expect(typeof p).toBe("string");
}
});

it("includes /opt/homebrew/bin and /usr/local/bin", () => {
const paths = _unixExtraPaths();
expect(paths).toContain("/opt/homebrew/bin");
expect(paths).toContain("/usr/local/bin");
});
});
3 changes: 3 additions & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ export {
escapeCmdArg,
_buildSpawnConfig,
_pickBestMatch,
_augmentUnixPath,
_resetAugmentCache,
_unixExtraPaths,
type RunResult,
type RunOptions,
type SpawnConfig,
Expand Down
77 changes: 77 additions & 0 deletions packages/shared/src/runner.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,82 @@
import { spawn, execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { join, normalize } from "node:path";
import { stripAnsi } from "./ansi.js";
import { sanitizeErrorOutput } from "./sanitize.js";

// ---------------------------------------------------------------------------
// Unix PATH augmentation
//
// MCP clients (Codex, Claude Desktop, etc.) often launch server processes with
// a stripped PATH that omits common tool directories like /opt/homebrew/bin.
// This mirrors the Windows _WIN32_FALLBACK_PATHS approach: detect missing
// directories and prepend them so spawned commands can find tools like fd, rg,
// docker, etc.
// ---------------------------------------------------------------------------

/**
* Common Unix directories where dev tools are installed.
* Evaluated lazily (HOME resolved at call time, not import time).
*
* @internal — Exported for unit testing only.
*/
export function _unixExtraPaths(): string[] {
const home = homedir();
return [
"/opt/homebrew/bin", // macOS Apple Silicon Homebrew
"/opt/homebrew/sbin",
"/usr/local/bin", // Intel Mac Homebrew / general
"/usr/local/sbin",
join(home, ".cargo", "bin"), // Rust
join(home, ".local", "bin"), // Python / pipx
];
}

/** Cached result — `undefined` means not yet computed. */
let _augmented = false;

/**
* Prepends well-known Unix tool directories to `process.env.PATH` if they
* exist on disk but are not already present. No-op on Windows.
*
* Safe to call multiple times — computes once and caches.
*
* @internal — Exported for unit testing only.
*/
export function _augmentUnixPath(
platform: string = process.platform,
env: NodeJS.ProcessEnv = process.env,
): void {
if (platform === "win32") return;
if (_augmented) return;
_augmented = true;

const currentPath = env.PATH ?? "";
const currentDirs = new Set(currentPath.split(":").filter(Boolean));
const toAdd: string[] = [];

for (const dir of _unixExtraPaths()) {
if (!currentDirs.has(dir) && existsSync(dir)) {
toAdd.push(dir);
}
}

if (toAdd.length > 0) {
env.PATH = [...toAdd, currentPath].filter(Boolean).join(":");
}
}

/**
* Resets the augmentation cache so `_augmentUnixPath` can run again.
* Only needed in tests.
*
* @internal — Exported for unit testing only.
*/
export function _resetAugmentCache(): void {
_augmented = false;
}

/**
* Resolves a command name to its absolute path using `where` (Windows) or
* `which` (Unix). On Windows this is used to detect whether a command is a
Expand Down Expand Up @@ -359,6 +432,10 @@ export function _buildSpawnConfig(
* Normal non-zero exit codes are returned in the result, not thrown.
*/
export function run(cmd: string, args: string[], opts?: RunOptions): Promise<RunResult> {
// Ensure common Unix tool directories are on PATH before spawning.
// Computed once and cached — safe to call on every invocation.
_augmentUnixPath();

return new Promise((resolve, reject) => {
const config = _buildSpawnConfig(cmd, args, { shell: opts?.shell });

Expand Down
Loading