fix: include pnpm 11 bins in gateway PATH (#85238)

* fix: include pnpm 11 bins in gateway PATH

* fix: include pnpm 11 bins in gateway PATH

* fix(security): reject workspace package-manager PATH roots

* fix(infra): preserve package paths from root cwd

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Santiago
2026-07-07 07:49:21 +02:00
committed by GitHub
parent 094c0d421f
commit 80bb0cd24e
6 changed files with 287 additions and 8 deletions
+37 -4
View File
@@ -36,6 +36,7 @@ describe("getMinimalServicePathParts - Linux user directories", () => {
expect(result).toContain("/home/testuser/.fnm/current/bin");
expect(result).toContain("/home/testuser/.volta/bin");
expect(result).toContain("/home/testuser/.asdf/shims");
expect(result).toContain("/home/testuser/.local/share/pnpm/bin");
expect(result).toContain("/home/testuser/.local/share/pnpm");
expect(result).toContain("/home/testuser/.bun/bin");
});
@@ -50,7 +51,7 @@ describe("getMinimalServicePathParts - Linux user directories", () => {
expect(result).toEqual(["/usr/local/bin", "/usr/bin", "/bin"]);
});
it("places user directories before system directories on Linux", () => {
it("places user directories after system directories on Linux", () => {
const result = getMinimalServicePathParts({
platform: "linux",
home: "/home/testuser",
@@ -62,7 +63,33 @@ describe("getMinimalServicePathParts - Linux user directories", () => {
expect(userDirIndex).toBeGreaterThan(-1);
expect(systemDirIndex).toBeGreaterThan(-1);
expect(userDirIndex).toBeLessThan(systemDirIndex);
expect(systemDirIndex).toBeLessThan(userDirIndex);
});
it("places package-manager bin directories after trusted system directories on Linux", () => {
const result = getMinimalServicePathPartsFromEnv({
platform: "linux",
env: {
HOME: "/home/testuser",
PNPM_HOME: "/home/testuser/.local/share/pnpm",
NPM_CONFIG_PREFIX: "/home/testuser/.npm-global",
},
existsSync: allExist,
});
const systemDirIndex = result.indexOf("/usr/bin");
const packageManagerDirs = [
"/home/testuser/.local/share/pnpm",
"/home/testuser/.local/share/pnpm/bin",
"/home/testuser/.npm-global/bin",
];
expect(systemDirIndex).toBeGreaterThan(-1);
for (const dir of packageManagerDirs) {
const dirIndex = result.indexOf(dir);
expect(dirIndex).toBeGreaterThan(-1);
expect(systemDirIndex).toBeLessThan(dirIndex);
}
});
it("places extraDirs before user directories on Linux", () => {
@@ -98,6 +125,7 @@ describe("getMinimalServicePathParts - Linux user directories", () => {
});
expect(result).toContain("/opt/pnpm");
expect(result).toContain("/opt/pnpm/bin");
expect(result).toContain("/opt/npm/bin");
expect(result).toContain("/opt/bun/bin");
expect(result).toContain("/opt/volta/bin");
@@ -222,7 +250,9 @@ describe("getMinimalServicePathParts - Linux user directories", () => {
"/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/bin");
expect(result).not.toContain("/Users/testuser/Library/pnpm");
expect(result).not.toContain("/Users/testuser/.local/share/pnpm/bin");
expect(result).not.toContain("/Users/testuser/.local/share/pnpm");
});
@@ -256,6 +286,7 @@ describe("getMinimalServicePathParts - Linux user directories", () => {
});
expect(result).toContain("/opt/pnpm");
expect(result).toContain("/opt/pnpm/bin");
expect(result).toContain("/opt/volta/bin");
expect(result).toContain("/opt/bun/bin");
expect(result).toContain("/opt/asdf/shims");
@@ -333,6 +364,7 @@ describe("getMinimalServicePathParts - Linux user directories", () => {
});
expect(result).toContain("/home/testuser/.local/share/pnpm");
expect(result).toContain("/home/testuser/.local/share/pnpm/bin");
expect(result).toContain("/home/testuser/.local/share/fnm/aliases/default/bin");
expect(result).toContain("/home/testuser/.local/share/fnm/current/bin");
});
@@ -515,6 +547,7 @@ describe("buildMinimalServicePath", () => {
// Verify user directories are included
expect(parts).toContain("/home/alice/.local/bin");
expect(parts).toContain("/home/alice/.npm-global/bin");
expect(parts).toContain("/home/alice/.local/share/pnpm/bin");
expect(parts).toContain("/home/alice/.nvm/current/bin");
expect(parts).toContain("/home/alice/.local/share/fnm/aliases/default/bin");
@@ -535,7 +568,7 @@ describe("buildMinimalServicePath", () => {
expect(parts).toEqual(["/usr/local/bin", "/usr/bin", "/bin"]);
});
it("ensures user directories come before system directories on Linux", () => {
it("ensures user directories come after system directories on Linux", () => {
const result = buildMinimalServicePath({
platform: "linux",
env: { HOME: "/home/bob" },
@@ -546,7 +579,7 @@ describe("buildMinimalServicePath", () => {
const firstUserDirIdx = parts.indexOf("/home/bob/.local/bin");
const firstSystemDirIdx = parts.indexOf("/usr/local/bin");
expect(firstUserDirIdx).toBeLessThan(firstSystemDirIdx);
expect(firstSystemDirIdx).toBeLessThan(firstUserDirIdx);
});
it("includes extra directories when provided", () => {
+7 -3
View File
@@ -194,6 +194,7 @@ function addCommonEnvConfiguredBinDirs(
options: Pick<MinimalServicePathOptions, "cwd" | "home">,
): void {
addEnvConfiguredBinDir(dirs, env?.PNPM_HOME, options);
addEnvConfiguredBinDir(dirs, appendSubdir(env?.PNPM_HOME, "bin"), options);
addEnvConfiguredBinDir(dirs, appendSubdir(env?.NPM_CONFIG_PREFIX, "bin"), options);
addEnvConfiguredBinDir(dirs, appendSubdir(env?.BUN_INSTALL, "bin"), options);
addEnvConfiguredBinDir(dirs, appendSubdir(env?.VOLTA_HOME, "bin"), options);
@@ -274,7 +275,7 @@ function resolveDarwinUserBinDirs(
addEnvConfiguredBinDir(dirs, env?.NVM_DIR, pathOptions);
// fnm: use aliases/default (not current)
addEnvConfiguredBinDir(dirs, appendSubdir(env?.FNM_DIR, "aliases/default/bin"), pathOptions);
// pnpm: binary is directly in PNPM_HOME (not in bin subdirectory)
// pnpm 10 placed binaries directly in PNPM_HOME; pnpm 11 uses PNPM_HOME/bin.
// Common user bin directories
addCommonUserBinDirs(dirs, home, existsSync, includeMissingUserBinDefaults);
@@ -288,7 +289,9 @@ function resolveDarwinUserBinDirs(
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
addExistingDir(dirs, `${home}/Library/pnpm/bin`, existsSync); // pnpm 11 default
addExistingDir(dirs, `${home}/Library/pnpm`, existsSync); // pnpm default
addExistingDir(dirs, `${home}/.local/share/pnpm/bin`, existsSync); // pnpm 11 XDG fallback
addExistingDir(dirs, `${home}/.local/share/pnpm`, existsSync); // pnpm XDG fallback
return dirs;
@@ -330,6 +333,7 @@ function resolveLinuxUserBinDirs(
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/bin`, existsSync); // pnpm 11 global bin
addExistingDir(dirs, `${home}/.local/share/pnpm`, existsSync); // pnpm global bin
return dirs;
@@ -369,10 +373,10 @@ export function getMinimalServicePathParts(options: MinimalServicePathOptions =
for (const dir of extraDirs) {
add(dir);
}
for (const dir of userDirs) {
for (const dir of systemDirs) {
add(dir);
}
for (const dir of systemDirs) {
for (const dir of userDirs) {
add(dir);
}
+4
View File
@@ -456,18 +456,22 @@ describe("loadDotEnv", () => {
await withDotEnvFixture(async ({ base, cwdDir }) => {
const bundledPluginsDir = path.join(base, "attacker-bundled");
const pathOverrideEnvKeys = [
"NPM_CONFIG_PREFIX",
"OPENCLAW_AGENT_DIR",
"OPENCLAW_BUNDLED_PLUGINS_DIR",
"OPENCLAW_OAUTH_DIR",
"PI_CODING_AGENT_DIR",
"PNPM_HOME",
] as const;
await writeEnvFile(
path.join(cwdDir, ".env"),
[
`NPM_CONFIG_PREFIX=${path.join(cwdDir, ".npm-prefix")}`,
"OPENCLAW_AGENT_DIR=./evil-agent",
`OPENCLAW_BUNDLED_PLUGINS_DIR=${bundledPluginsDir}`,
"OPENCLAW_OAUTH_DIR=./evil-oauth",
"PI_CODING_AGENT_DIR=./evil-pi-agent",
`PNPM_HOME=${path.join(cwdDir, ".pnpm")}`,
].join("\n"),
);
+2
View File
@@ -117,7 +117,9 @@ const BLOCKED_WORKSPACE_DOTENV_KEYS = new Set([
"MINIMAX_API_HOST",
"NODE_TLS_REJECT_UNAUTHORIZED",
"NO_PROXY",
"NPM_CONFIG_PREFIX",
"NPM_EXECPATH",
"PNPM_HOME",
"OPENAI_API_KEYS",
"OPENCLAW_AGENT_DIR",
"OPENCLAW_ALLOW_PLUGIN_INSTALL_OVERRIDES",
+150 -1
View File
@@ -1,4 +1,5 @@
// Covers OpenClaw CLI PATH construction.
import fs from "node:fs";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ensureOpenClawCliOnPath } from "./path-env.js";
@@ -51,6 +52,8 @@ describe("ensureOpenClawCliOnPath", () => {
"OPENCLAW_PATH_BOOTSTRAPPED",
"OPENCLAW_ALLOW_PROJECT_LOCAL_BIN",
"MISE_DATA_DIR",
"PNPM_HOME",
"NPM_CONFIG_PREFIX",
"HOMEBREW_PREFIX",
"HOMEBREW_BREW_FILE",
"XDG_BIN_HOME",
@@ -106,6 +109,8 @@ describe("ensureOpenClawCliOnPath", () => {
delete process.env.HOMEBREW_PREFIX;
delete process.env.HOMEBREW_BREW_FILE;
delete process.env.XDG_BIN_HOME;
delete process.env.PNPM_HOME;
delete process.env.NPM_CONFIG_PREFIX;
}
function expectPathsAfter(parts: string[], anchor: string, expectedPaths: string[]) {
@@ -292,12 +297,17 @@ describe("ensureOpenClawCliOnPath", () => {
it("places all user-writable home dirs after system dirs", () => {
const { tmp, appCli } = setupAppCliRoot("case-user-writable-after-system");
const localBin = path.join(tmp, ".local", "bin");
const npmGlobalBin = path.join(tmp, ".npm-global", "bin");
const pnpm11Bin = path.join(tmp, ".local", "share", "pnpm", "bin");
const pnpmBin = path.join(tmp, ".local", "share", "pnpm");
const bunBin = path.join(tmp, ".bun", "bin");
const yarnBin = path.join(tmp, ".yarn", "bin");
setDir(path.join(tmp, ".local"));
setDir(localBin);
setDir(path.join(tmp, ".npm-global"));
setDir(npmGlobalBin);
setDir(path.join(tmp, ".local", "share"));
setDir(pnpm11Bin);
setDir(pnpmBin);
setDir(path.join(tmp, ".bun"));
setDir(bunBin);
@@ -312,7 +322,146 @@ describe("ensureOpenClawCliOnPath", () => {
homeDir: tmp,
platform: "linux",
});
expectPathsAfter(updated, "/usr/bin", [localBin, pnpmBin, bunBin, yarnBin]);
expectPathsAfter(updated, "/usr/bin", [
localBin,
npmGlobalBin,
pnpm11Bin,
pnpmBin,
bunBin,
yarnBin,
]);
});
it("appends package-manager env bin dirs after system dirs", () => {
const { tmp, appCli } = setupAppCliRoot("case-package-manager-env");
const pnpmHome = path.join(tmp, "pnpm-home");
const pnpmHomeBin = path.join(pnpmHome, "bin");
const npmPrefix = path.join(tmp, "npm-prefix");
const npmPrefixBin = path.join(npmPrefix, "bin");
setDir(pnpmHome);
setDir(pnpmHomeBin);
setDir(npmPrefix);
setDir(npmPrefixBin);
resetBootstrapEnv("/usr/bin:/bin");
process.env.PNPM_HOME = pnpmHome;
process.env.NPM_CONFIG_PREFIX = npmPrefix;
const updated = bootstrapPath({
execPath: appCli,
cwd: tmp,
homeDir: tmp,
platform: "linux",
});
expectPathsAfter(updated, "/usr/bin", [pnpmHome, pnpmHomeBin, npmPrefixBin]);
});
it("keeps package-manager env roots when cwd is the filesystem root", () => {
const { tmp, appCli } = setupAppCliRoot("case-package-manager-root-cwd");
const pnpmHome = path.join(tmp, "pnpm-home");
const pnpmHomeBin = path.join(pnpmHome, "bin");
const npmPrefix = path.join(tmp, "npm-prefix");
const npmPrefixBin = path.join(npmPrefix, "bin");
for (const dir of [pnpmHome, pnpmHomeBin, npmPrefix, npmPrefixBin]) {
setDir(dir);
}
resetBootstrapEnv("/usr/bin:/bin");
process.env.PNPM_HOME = pnpmHome;
process.env.NPM_CONFIG_PREFIX = npmPrefix;
const updated = bootstrapPath({
execPath: appCli,
cwd: path.parse(tmp).root,
homeDir: tmp,
platform: "linux",
});
expectPathsAfter(updated, "/usr/bin", [pnpmHome, pnpmHomeBin, npmPrefixBin]);
});
it("ignores relative package-manager env roots", () => {
const { tmp, appCli } = setupAppCliRoot("case-package-manager-relative");
resetBootstrapEnv("/usr/bin:/bin");
process.env.PNPM_HOME = ".";
process.env.NPM_CONFIG_PREFIX = "npm-prefix";
const updated = bootstrapPath({
execPath: appCli,
cwd: tmp,
homeDir: tmp,
platform: "linux",
});
expect(updated).not.toContain(".");
expect(updated).not.toContain("bin");
expect(updated).not.toContain(path.join("npm-prefix", "bin"));
});
it("ignores package-manager env roots derived from the active workspace", () => {
const homeDir = abs("/tmp/openclaw-path/home");
const cwd = path.join(homeDir, "workspace");
const appBinDir = path.join(homeDir, "app-bin");
const appCli = path.join(appBinDir, "openclaw");
const pnpmHome = path.join(cwd, ".pnpm");
const npmPrefix = path.join(cwd, ".npm-prefix");
for (const dir of [homeDir, cwd, appBinDir, pnpmHome, path.join(pnpmHome, "bin"), npmPrefix]) {
setDir(dir);
}
setDir(path.join(npmPrefix, "bin"));
setExe(appCli);
resetBootstrapEnv("/usr/bin:/bin");
process.env.PNPM_HOME = pnpmHome;
process.env.NPM_CONFIG_PREFIX = npmPrefix;
const updated = bootstrapPath({
execPath: appCli,
cwd,
homeDir,
platform: "linux",
});
expect(updated).not.toContain(pnpmHome);
expect(updated).not.toContain(path.join(pnpmHome, "bin"));
expect(updated).not.toContain(path.join(npmPrefix, "bin"));
});
it("ignores package-manager env roots whose existing parent resolves into the workspace", () => {
const homeDir = abs("/tmp/openclaw-path/home");
const cwd = path.join(homeDir, "workspace");
const appBinDir = path.join(homeDir, "app-bin");
const appCli = path.join(appBinDir, "openclaw");
for (const dir of [homeDir, cwd, appBinDir]) {
setDir(dir);
}
setExe(appCli);
resetBootstrapEnv("/usr/bin:/bin");
process.env.PNPM_HOME = "/tmp/workspace-link/missing-pnpm-home";
const realpathNative = vi.spyOn(fs.realpathSync, "native").mockImplementation((candidate) => {
const value = String(candidate);
if (value === "/tmp/workspace-link") {
return cwd;
}
if (value === cwd || value === homeDir) {
return value;
}
throw Object.assign(new Error("missing"), { code: "ENOENT" });
});
try {
const updated = bootstrapPath({
execPath: appCli,
cwd,
homeDir,
platform: "linux",
});
expect(updated).not.toContain(process.env.PNPM_HOME);
expect(updated).not.toContain(path.join(process.env.PNPM_HOME, "bin"));
} finally {
realpathNative.mockRestore();
}
});
it.each([
+87
View File
@@ -50,6 +50,73 @@ function isKnownPathDir(existingPathParts: ReadonlySet<string>, dirPath: string)
return existingPathParts.has(dirPath) || isDirectory(dirPath);
}
function realpathExistingPath(candidate: string): string | undefined {
const suffix: string[] = [];
let current = candidate;
while (true) {
try {
return path.resolve(fs.realpathSync.native(current), ...suffix.toReversed());
} catch {
const parent = path.dirname(current);
if (parent === current) {
return undefined;
}
suffix.push(path.basename(current));
current = parent;
}
}
}
function isSameOrChildPath(candidate: string, parent: string): boolean {
const relative = path.relative(parent, candidate);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function isFilesystemRoot(dirPath: string): boolean {
return path.dirname(dirPath) === dirPath;
}
function normalizeTrustedPackageManagerRoot(params: {
value: string | undefined;
cwd: string | undefined;
homeDir: string;
}): string | undefined {
const trimmed = params.value?.trim();
if (!trimmed || !path.isAbsolute(trimmed)) {
return undefined;
}
const normalized = path.normalize(trimmed);
if (normalized === "/proc" || normalized.startsWith(`/proc${path.sep}`)) {
return undefined;
}
if (!params.cwd) {
return normalized;
}
const cwd = path.resolve(params.cwd);
const homeDir = path.resolve(params.homeDir);
if (cwd === homeDir || isFilesystemRoot(cwd)) {
return normalized;
}
if (isSameOrChildPath(normalized, cwd)) {
return undefined;
}
const realCandidate = realpathExistingPath(normalized);
const realCwd = realpathExistingPath(cwd);
const realHome = realpathExistingPath(homeDir);
if (
realCwd &&
realCwd !== realHome &&
!isFilesystemRoot(realCwd) &&
realCandidate &&
isSameOrChildPath(realCandidate, realCwd)
) {
return undefined;
}
return normalized;
}
function isLinuxbrewPath(dirPath: string): boolean {
return dirPath.split(path.sep).includes(".linuxbrew");
}
@@ -131,18 +198,38 @@ function candidateBinDirs(
// This includes Brew/Homebrew dirs, which are useful for finding `openclaw`
// in launchd/minimal environments but must not be treated as trusted.
append.push(...resolvePathBootstrapBrewDirs({ homeDir, platform, existingPathParts }));
const pnpmHome = normalizeTrustedPackageManagerRoot({
value: process.env.PNPM_HOME,
cwd,
homeDir,
});
if (pnpmHome) {
append.push(pnpmHome);
append.push(path.join(pnpmHome, "bin"));
}
const npmPrefix = normalizeTrustedPackageManagerRoot({
value: process.env.NPM_CONFIG_PREFIX,
cwd,
homeDir,
});
if (npmPrefix) {
append.push(path.join(npmPrefix, "bin"));
}
const miseDataDir = process.env.MISE_DATA_DIR ?? path.join(homeDir, ".local", "share", "mise");
const miseShims = path.join(miseDataDir, "shims");
if (isKnownPathDir(existingPathParts, miseShims)) {
append.push(miseShims);
}
if (platform === "darwin") {
append.push(path.join(homeDir, "Library", "pnpm", "bin"));
append.push(path.join(homeDir, "Library", "pnpm"));
}
if (process.env.XDG_BIN_HOME) {
append.push(process.env.XDG_BIN_HOME);
}
append.push(path.join(homeDir, ".local", "bin"));
append.push(path.join(homeDir, ".npm-global", "bin"));
append.push(path.join(homeDir, ".local", "share", "pnpm", "bin"));
append.push(path.join(homeDir, ".local", "share", "pnpm"));
append.push(path.join(homeDir, ".bun", "bin"));
append.push(path.join(homeDir, ".yarn", "bin"));