Skip to content

Commit 0a8d905

Browse files
committed
fix: include npm global bins in gateway path
1 parent b8a5d76 commit 0a8d905

3 files changed

Lines changed: 172 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
2222
### Fixes
2323

2424
- Agents/UI: compact exec and tool progress rows by hiding redundant shell tool names, replacing known workspace paths with short context markers, and preserving Discord trace scrubbing for compact command lines.
25+
- Gateway/Skills: include npm-global and configured npm prefix bin directories in gateway PATH bootstrap, so npm-installed skill CLIs such as `clawhub` and `mcporter` no longer appear as missing in Skills status. Fixes #80206. Thanks @AdoShan.
2526
- ACPX: run and await the embedded ACP backend startup probe by default so the gateway `ready` signal no longer fires before the acpx runtime has either become usable or reported a probe failure; set `OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE=0` to restore lazy startup. Fixes #79596. Thanks @bzelones.
2627
- OpenAI-compatible models: strip prior assistant reasoning fields from replayed Chat Completions history by default, preventing oMLX/vLLM Qwen follow-up turns from rejecting or stalling on stale `reasoning` payloads. Fixes #46637. Thanks @zipzagster and @lexhoefsloot.
2728
- CLI/onboarding: give non-Azure custom providers a safe generated context window and heal legacy 4k wizard entries without overwriting explicit valid small model limits, preventing first-turn compaction loops. Fixes #79428. (#79911) Thanks @Jefsky.

src/infra/path-env.test.ts

Lines changed: 103 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { PathLike } from "node:fs";
12
import path from "node:path";
23
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
34
import { ensureOpenClawCliOnPath } from "./path-env.js";
@@ -12,32 +13,46 @@ const setDir = (p: string) => state.dirs.add(abs(p));
1213
const setExe = (p: string) => state.executables.add(abs(p));
1314

1415
vi.mock("node:fs", async () => {
16+
const { mockNodeBuiltinModule } = await import("openclaw/plugin-sdk/test-node-mocks");
1517
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
1618
const pathMod = await import("node:path");
17-
const absInMock = (p: string) => pathMod.resolve(p);
18-
19-
const wrapped = {
20-
...actual,
21-
constants: { ...actual.constants, X_OK: actual.constants.X_OK ?? 1 },
22-
accessSync: (p: string, mode?: number) => {
23-
const resolved = absInMock(p);
24-
if (state.executables.has(resolved)) {
25-
return;
26-
}
27-
actual.accessSync(p, mode);
28-
},
29-
statSync: (p: string) => {
30-
const resolved = absInMock(p);
31-
if (state.dirs.has(resolved)) {
32-
return {
33-
isDirectory: () => true,
34-
};
35-
}
36-
return actual.statSync(p);
37-
},
38-
};
19+
const absInMock = (p: PathLike) =>
20+
pathMod.resolve(typeof p === "string" ? p : p instanceof URL ? p.pathname : p.toString());
21+
const actualReadFileSync = actual.readFileSync.bind(actual);
3922

40-
return { ...wrapped, default: wrapped };
23+
return mockNodeBuiltinModule(
24+
() => vi.importActual<typeof import("node:fs")>("node:fs"),
25+
{
26+
constants: { ...actual.constants, X_OK: actual.constants.X_OK ?? 1 },
27+
accessSync: ((p: PathLike, mode?: number) => {
28+
const resolved = absInMock(p);
29+
if (state.executables.has(resolved)) {
30+
return;
31+
}
32+
actual.accessSync(p, mode);
33+
}) as typeof actual.accessSync,
34+
statSync: ((p: PathLike) => {
35+
const resolved = absInMock(p);
36+
if (state.dirs.has(resolved)) {
37+
return {
38+
isDirectory: () => true,
39+
};
40+
}
41+
return actual.statSync(p);
42+
}) as typeof actual.statSync,
43+
readFileSync: ((
44+
p: PathLike | number,
45+
options?: BufferEncoding | { encoding?: BufferEncoding | null },
46+
) => {
47+
const resolved = typeof p === "number" ? "" : absInMock(p);
48+
if (resolved.endsWith(pathMod.join("case-npmrc-prefix", ".npmrc"))) {
49+
return "prefix=~/.npm-packages\n";
50+
}
51+
return actualReadFileSync(p, options as never);
52+
}) as typeof actual.readFileSync,
53+
},
54+
{ mirrorToDefault: true },
55+
);
4156
});
4257

4358
vi.mock("./env.js", () => ({
@@ -53,6 +68,8 @@ describe("ensureOpenClawCliOnPath", () => {
5368
"HOMEBREW_PREFIX",
5469
"HOMEBREW_BREW_FILE",
5570
"XDG_BIN_HOME",
71+
"NPM_CONFIG_PREFIX",
72+
"npm_config_prefix",
5673
] as const;
5774
let envSnapshot: Record<(typeof envKeys)[number], string | undefined>;
5875

@@ -105,6 +122,8 @@ describe("ensureOpenClawCliOnPath", () => {
105122
delete process.env.HOMEBREW_PREFIX;
106123
delete process.env.HOMEBREW_BREW_FILE;
107124
delete process.env.XDG_BIN_HOME;
125+
delete process.env.NPM_CONFIG_PREFIX;
126+
delete process.env.npm_config_prefix;
108127
}
109128

110129
function expectPathsAfter(parts: string[], anchor: string, expectedPaths: string[]) {
@@ -271,11 +290,14 @@ describe("ensureOpenClawCliOnPath", () => {
271290
it("places all user-writable home dirs after system dirs", () => {
272291
const { tmp, appCli } = setupAppCliRoot("case-user-writable-after-system");
273292
const localBin = path.join(tmp, ".local", "bin");
293+
const npmGlobalBin = path.join(tmp, ".npm-global", "bin");
274294
const pnpmBin = path.join(tmp, ".local", "share", "pnpm");
275295
const bunBin = path.join(tmp, ".bun", "bin");
276296
const yarnBin = path.join(tmp, ".yarn", "bin");
277297
setDir(path.join(tmp, ".local"));
278298
setDir(localBin);
299+
setDir(path.join(tmp, ".npm-global"));
300+
setDir(npmGlobalBin);
279301
setDir(path.join(tmp, ".local", "share"));
280302
setDir(pnpmBin);
281303
setDir(path.join(tmp, ".bun"));
@@ -291,7 +313,64 @@ describe("ensureOpenClawCliOnPath", () => {
291313
homeDir: tmp,
292314
platform: "linux",
293315
});
294-
expectPathsAfter(updated, "/usr/bin", [localBin, pnpmBin, bunBin, yarnBin]);
316+
expectPathsAfter(updated, "/usr/bin", [localBin, npmGlobalBin, pnpmBin, bunBin, yarnBin]);
317+
});
318+
319+
it("appends npm global prefix bin dirs after system dirs", () => {
320+
const { tmp, appCli } = setupAppCliRoot("case-npm-prefix");
321+
const npmPrefix = path.join(tmp, "npm-prefix");
322+
const npmPrefixBin = path.join(npmPrefix, "bin");
323+
const npmConfigPrefix = path.join(tmp, "npm-config-prefix");
324+
const npmConfigPrefixBin = path.join(npmConfigPrefix, "bin");
325+
const managedNpmBin = path.join(tmp, ".openclaw", "tools", "node", "npm", "bin");
326+
const npmGlobalBin = path.join(tmp, ".npm-global", "bin");
327+
setDir(npmPrefix);
328+
setDir(npmPrefixBin);
329+
setDir(npmConfigPrefix);
330+
setDir(npmConfigPrefixBin);
331+
setDir(path.join(tmp, ".openclaw"));
332+
setDir(path.join(tmp, ".openclaw", "tools"));
333+
setDir(path.join(tmp, ".openclaw", "tools", "node"));
334+
setDir(path.join(tmp, ".openclaw", "tools", "node", "npm"));
335+
setDir(managedNpmBin);
336+
setDir(path.join(tmp, ".npm-global"));
337+
setDir(npmGlobalBin);
338+
339+
resetBootstrapEnv("/usr/bin:/bin");
340+
process.env.NPM_CONFIG_PREFIX = npmPrefix;
341+
process.env.npm_config_prefix = npmConfigPrefix;
342+
343+
const updated = bootstrapPath({
344+
execPath: appCli,
345+
cwd: tmp,
346+
homeDir: tmp,
347+
platform: "darwin",
348+
});
349+
350+
expectPathsAfter(updated, "/usr/bin", [
351+
npmPrefixBin,
352+
npmConfigPrefixBin,
353+
managedNpmBin,
354+
npmGlobalBin,
355+
]);
356+
});
357+
358+
it("appends user .npmrc prefix bin dirs after system dirs", () => {
359+
const { tmp, appCli } = setupAppCliRoot("case-npmrc-prefix");
360+
const npmrcPrefixBin = path.join(tmp, ".npm-packages", "bin");
361+
setDir(path.join(tmp, ".npm-packages"));
362+
setDir(npmrcPrefixBin);
363+
364+
resetBootstrapEnv("/usr/bin:/bin");
365+
366+
const updated = bootstrapPath({
367+
execPath: appCli,
368+
cwd: tmp,
369+
homeDir: tmp,
370+
platform: "darwin",
371+
});
372+
373+
expectPathsAfter(updated, "/usr/bin", [npmrcPrefixBin]);
295374
});
296375

297376
it.each([

src/infra/path-env.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,73 @@ function isDirectory(dirPath: string): boolean {
3030
}
3131
}
3232

33+
function expandHomeDir(candidate: string, homeDir: string): string {
34+
const withHomeEnv = candidate.replace(/\$\{HOME\}/g, homeDir);
35+
if (withHomeEnv !== candidate) {
36+
return withHomeEnv;
37+
}
38+
if (candidate === "~") {
39+
return homeDir;
40+
}
41+
if (candidate.startsWith("~/") || candidate.startsWith("~\\")) {
42+
return path.join(homeDir, candidate.slice(2));
43+
}
44+
return candidate;
45+
}
46+
47+
function resolveNpmPrefixBinDir(
48+
prefix: string | undefined,
49+
homeDir: string,
50+
platform: NodeJS.Platform,
51+
): string | undefined {
52+
const trimmed = prefix?.trim().replace(/^['"]|['"]$/g, "");
53+
if (!trimmed) {
54+
return undefined;
55+
}
56+
const expanded = expandHomeDir(trimmed, homeDir);
57+
if (!path.isAbsolute(expanded)) {
58+
return undefined;
59+
}
60+
return platform === "win32" ? path.normalize(expanded) : path.join(expanded, "bin");
61+
}
62+
63+
function readUserNpmPrefix(homeDir: string): string | undefined {
64+
try {
65+
const npmrc = fs.readFileSync(path.join(homeDir, ".npmrc"), "utf8");
66+
let prefix: string | undefined;
67+
for (const line of npmrc.split(/\r?\n/)) {
68+
const trimmed = line.trim();
69+
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";")) {
70+
continue;
71+
}
72+
const match = /^prefix\s*=\s*(.+)$/i.exec(trimmed);
73+
if (match) {
74+
prefix = match[1]?.trim();
75+
}
76+
}
77+
return prefix;
78+
} catch {
79+
return undefined;
80+
}
81+
}
82+
83+
function resolveNpmGlobalBinDirs(opts: { homeDir: string; platform: NodeJS.Platform }): string[] {
84+
const dirs: string[] = [];
85+
for (const prefix of [
86+
process.env.NPM_CONFIG_PREFIX,
87+
process.env.npm_config_prefix,
88+
readUserNpmPrefix(opts.homeDir),
89+
path.join(opts.homeDir, ".openclaw", "tools", "node", "npm"),
90+
]) {
91+
const binDir = resolveNpmPrefixBinDir(prefix, opts.homeDir, opts.platform);
92+
if (binDir) {
93+
dirs.push(binDir);
94+
}
95+
}
96+
dirs.push(path.join(opts.homeDir, ".npm-global", "bin"));
97+
return dirs;
98+
}
99+
33100
function mergePath(params: { existing: string; prepend?: string[]; append?: string[] }): string {
34101
const partsExisting = params.existing
35102
.split(path.delimiter)
@@ -113,6 +180,7 @@ function candidateBinDirs(opts: EnsureOpenClawPathOpts): { prepend: string[]; ap
113180
append.push(process.env.XDG_BIN_HOME);
114181
}
115182
append.push(path.join(homeDir, ".local", "bin"));
183+
append.push(...resolveNpmGlobalBinDirs({ homeDir, platform }));
116184
append.push(path.join(homeDir, ".local", "share", "pnpm"));
117185
append.push(path.join(homeDir, ".bun", "bin"));
118186
append.push(path.join(homeDir, ".yarn", "bin"));

0 commit comments

Comments
 (0)