fix(update): preserve pnpm custom global root (#78393)

Co-authored-by: Alex Knight <15041791+amknight@users.noreply.github.com>
This commit is contained in:
Alex Knight
2026-05-06 22:46:21 +10:00
committed by GitHub
parent 2d5df741f5
commit 77480212c7
6 changed files with 252 additions and 12 deletions

View File

@@ -118,6 +118,7 @@ Docs: https://docs.openclaw.ai
- Web fetch: bound guarded dispatcher cleanup after request timeouts so timed-out fetches return tool errors instead of leaving Gateway tool lanes active. (#78439) Thanks @obviyus.
- Gate Slack startup user allowlist resolution [AI]. (#77898) Thanks @pgondhi987.
- OpenAI/Codex: suppress stale `openai-codex` GPT-5.1/5.2/5.3 model refs that ChatGPT/Codex OAuth accounts now reject, keeping model lists, config validation, and forward-compat resolution on current 5.4/5.5 routes. Fixes #67158. Thanks @drpau.
- CLI/update: keep pnpm package updates on the running custom global install root and pass pnpm's `--global-dir` so `openclaw update` does not create a second default-prefix install when `OPENCLAW_HOME` or the shell points at a custom OpenClaw directory. Fixes #78377. Thanks @amknight.
- Google Meet/Voice Call: wait longer before playing PIN-derived Twilio DTMF for Meet dial-in prompts and retire stale delegated phone sessions instead of reusing completed calls.
- PDF/Codex: include extraction-fallback instructions for `openai-codex/*` PDF tool requests so Codex Responses receives its required system prompt. Fixes #77872. Thanks @anyech.
- Onboard/channels: recover externalized channel plugins from stale `channels.<id>` config by falling back to `ensureChannelSetupPluginInstalled` via the trusted catalog when the plugin is missing on disk, so leftover `appId`/token entries no longer dead-end onboard with "<channel> plugin not available." (#78328) Thanks @sliverp.

View File

@@ -52,6 +52,7 @@ import {
globalInstallArgs,
resolveGlobalInstallTarget,
resolveGlobalInstallSpec,
resolvePnpmGlobalDirFromGlobalRoot,
} from "../../infra/update-global.js";
import { runGatewayUpdate, type UpdateRunResult } from "../../infra/update-runner.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js";
@@ -1070,9 +1071,13 @@ async function runGitUpdate(params: {
timeoutMs: effectiveTimeout,
pkgRoot: params.root,
});
const installLocation =
installTarget.manager === "pnpm"
? resolvePnpmGlobalDirFromGlobalRoot(installTarget.globalRoot)
: null;
const installStep = await runUpdateStep({
name: "global install",
argv: globalInstallArgs(installTarget, updateRoot),
argv: globalInstallArgs(installTarget, updateRoot, undefined, installLocation),
cwd: updateRoot,
env: installEnv,
timeoutMs: effectiveTimeout,

View File

@@ -179,7 +179,8 @@ describe("runGlobalPackageUpdateSteps", () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
try {
await withTempDir({ prefix: "openclaw-package-update-win32-pnpm-" }, async (base) => {
const globalRoot = path.join(base, "pnpm", "global", "5", "node_modules");
const globalDir = path.join(base, "pnpm", "global");
const globalRoot = path.join(globalDir, "5", "node_modules");
const packageRoot = path.join(globalRoot, "openclaw");
await writePackageRoot(packageRoot, "1.0.0");
@@ -187,7 +188,7 @@ describe("runGlobalPackageUpdateSteps", () => {
if (name !== "global update") {
throw new Error(`unexpected step ${name}`);
}
expect(argv).toEqual(["pnpm", "add", "-g", "openclaw@2.0.0"]);
expect(argv).toEqual(["pnpm", "add", "-g", "--global-dir", globalDir, "openclaw@2.0.0"]);
await writePackageRoot(packageRoot, "2.0.0");
return {
name,

View File

@@ -8,6 +8,7 @@ import {
globalInstallFallbackArgs,
resolveNpmGlobalPrefixLayoutFromGlobalRoot,
resolveNpmGlobalPrefixLayoutFromPrefix,
resolvePnpmGlobalDirFromGlobalRoot,
resolveExpectedInstalledVersionFromSpec,
resolveGlobalInstallTarget,
type CommandRunner,
@@ -359,14 +360,14 @@ export async function runGlobalPackageUpdateSteps(params: {
}
const installCommandTarget = stagedInstall?.installTarget ?? params.installTarget;
const installLocation =
stagedInstall?.prefix ??
(installCommandTarget.manager === "pnpm"
? resolvePnpmGlobalDirFromGlobalRoot(installCommandTarget.globalRoot)
: null);
const updateStep = await params.runStep({
name: "global update",
argv: globalInstallArgs(
installCommandTarget,
params.installSpec,
undefined,
stagedInstall?.prefix,
),
argv: globalInstallArgs(installCommandTarget, params.installSpec, undefined, installLocation),
...installCwd,
...installEnv,
timeoutMs: params.timeoutMs,

View File

@@ -29,6 +29,7 @@ import {
resolveGlobalRoot,
resolveNpmGlobalPrefixLayoutFromGlobalRoot,
resolveNpmGlobalPrefixLayoutFromPrefix,
resolvePnpmGlobalDirFromGlobalRoot,
type CommandRunner,
} from "./update-global.js";
@@ -416,6 +417,151 @@ describe("update global helpers", () => {
}
});
it("detects custom pnpm global layouts from the running package root", async () => {
await withTempDir({ prefix: "openclaw-update-pnpm-custom-root-" }, async (base) => {
const customGlobalDir = path.join(base, "custom-pnpm");
const customGlobalRoot = path.join(customGlobalDir, "5", "node_modules");
const pkgRoot = path.join(customGlobalRoot, "openclaw");
const defaultPnpmRoot = path.join(base, "default-pnpm", "5", "node_modules");
await fs.mkdir(pkgRoot, { recursive: true });
await fs.writeFile(
path.join(customGlobalDir, "5", "pnpm-lock.yaml"),
"lockfileVersion: '9.0'\n",
"utf8",
);
await fs.writeFile(
path.join(customGlobalRoot, ".modules.yaml"),
"layoutVersion: 5\n",
"utf8",
);
const runCommand: CommandRunner = async (argv) => {
if (argv[0] === "npm") {
return { stdout: "", stderr: "", code: 1 };
}
if (argv[0] === "pnpm") {
return { stdout: `${defaultPnpmRoot}\n`, stderr: "", code: 0 };
}
throw new Error(`unexpected command: ${argv.join(" ")}`);
};
await expect(detectGlobalInstallManagerForRoot(runCommand, pkgRoot, 1000)).resolves.toBe(
"pnpm",
);
await expect(
resolveGlobalInstallTarget({
manager: "pnpm",
runCommand,
timeoutMs: 1000,
pkgRoot,
}),
).resolves.toEqual({
manager: "pnpm",
command: "pnpm",
globalRoot: customGlobalRoot,
packageRoot: pkgRoot,
});
expect(resolvePnpmGlobalDirFromGlobalRoot(customGlobalRoot)).toBe(customGlobalDir);
});
});
it("detects custom pnpm global layouts from virtual-store package roots", async () => {
await withTempDir({ prefix: "openclaw-update-pnpm-virtual-root-" }, async (base) => {
const customGlobalDir = path.join(base, "custom-pnpm");
const customGlobalRoot = path.join(customGlobalDir, "5", "node_modules");
const pkgRoot = path.join(
customGlobalDir,
"5",
".pnpm",
"openclaw@file+..+pack+openclaw-2026.5.6.tgz",
"node_modules",
"openclaw",
);
const defaultPnpmRoot = path.join(base, "default-pnpm", "5", "node_modules");
await fs.mkdir(customGlobalRoot, { recursive: true });
await fs.mkdir(pkgRoot, { recursive: true });
await fs.writeFile(
path.join(customGlobalDir, "5", "pnpm-lock.yaml"),
"lockfileVersion: '9.0'\n",
"utf8",
);
await fs.writeFile(
path.join(customGlobalRoot, ".modules.yaml"),
"layoutVersion: 5\n",
"utf8",
);
const runCommand: CommandRunner = async (argv) => {
if (argv[0] === "npm") {
return { stdout: "", stderr: "", code: 1 };
}
if (argv[0] === "pnpm") {
return { stdout: `${defaultPnpmRoot}\n`, stderr: "", code: 0 };
}
throw new Error(`unexpected command: ${argv.join(" ")}`);
};
await expect(detectGlobalInstallManagerForRoot(runCommand, pkgRoot, 1000)).resolves.toBe(
"pnpm",
);
await expect(
resolveGlobalInstallTarget({
manager: "pnpm",
runCommand,
timeoutMs: 1000,
pkgRoot,
}),
).resolves.toEqual({
manager: "pnpm",
command: "pnpm",
globalRoot: customGlobalRoot,
packageRoot: path.join(customGlobalRoot, "openclaw"),
});
});
});
it("does not infer pnpm ownership without pnpm node_modules metadata", async () => {
await withTempDir({ prefix: "openclaw-update-pnpm-shape-only-" }, async (base) => {
const customGlobalDir = path.join(base, "custom-pnpm");
const customGlobalRoot = path.join(customGlobalDir, "5", "node_modules");
const pkgRoot = path.join(customGlobalRoot, "openclaw");
const defaultPnpmRoot = path.join(base, "default-pnpm", "5", "node_modules");
await fs.mkdir(pkgRoot, { recursive: true });
await fs.writeFile(
path.join(customGlobalDir, "5", "pnpm-lock.yaml"),
"lockfileVersion: '9.0'\n",
"utf8",
);
const runCommand: CommandRunner = async (argv) => {
if (argv[0] === "npm") {
return { stdout: "", stderr: "", code: 1 };
}
if (argv[0] === "pnpm") {
return { stdout: `${defaultPnpmRoot}\n`, stderr: "", code: 0 };
}
throw new Error(`unexpected command: ${argv.join(" ")}`);
};
await expect(
detectGlobalInstallManagerForRoot(runCommand, pkgRoot, 1000),
).resolves.toBeNull();
await expect(
resolveGlobalInstallTarget({
manager: "pnpm",
runCommand,
timeoutMs: 1000,
pkgRoot,
}),
).resolves.toEqual({
manager: "pnpm",
command: "pnpm",
globalRoot: defaultPnpmRoot,
packageRoot: path.join(defaultPnpmRoot, "openclaw"),
});
});
});
it("builds install argv and npm fallback argv", () => {
expect(resolveGlobalInstallCommand("npm")).toEqual({
manager: "npm",
@@ -457,6 +603,14 @@ describe("update global helpers", () => {
expect(
globalInstallArgs({ manager: "pnpm", command: "/opt/homebrew/bin/pnpm" }, "openclaw@latest"),
).toEqual(["/opt/homebrew/bin/pnpm", "add", "-g", "openclaw@latest"]);
expect(globalInstallArgs("pnpm", "openclaw@latest", null, "/opt/pnpm-global")).toEqual([
"pnpm",
"add",
"-g",
"--global-dir",
"/opt/pnpm-global",
"openclaw@latest",
]);
});
it("builds npm staged install argv with an explicit prefix", () => {

View File

@@ -484,6 +484,69 @@ function resolvePreferredNpmCommand(pkgRoot?: string | null): string | null {
return fsSync.existsSync(candidate) ? candidate : null;
}
function inferGlobalRootFromPackageRoot(pkgRoot?: string | null): string | null {
const trimmed = pkgRoot?.trim();
if (!trimmed) {
return null;
}
const normalized = path.resolve(trimmed);
const globalRoot = path.dirname(normalized);
return path.basename(globalRoot) === "node_modules" ? globalRoot : null;
}
function inferPnpmGlobalRootFromPackageRoot(pkgRoot?: string | null): string | null {
const directGlobalRoot = inferGlobalRootFromPackageRoot(pkgRoot);
if (resolvePnpmGlobalDirFromGlobalRoot(directGlobalRoot)) {
return directGlobalRoot;
}
const trimmed = pkgRoot?.trim();
if (!trimmed) {
return null;
}
const normalized = path.resolve(trimmed);
const parts = normalized.split(path.sep);
const pnpmIndex = parts.lastIndexOf(".pnpm");
if (pnpmIndex <= 0) {
return null;
}
if (parts[pnpmIndex + 2] !== "node_modules") {
return null;
}
const layoutDir = parts.slice(0, pnpmIndex).join(path.sep) || path.sep;
const globalRoot =
path.basename(layoutDir) === "node_modules" ? layoutDir : path.join(layoutDir, "node_modules");
return resolvePnpmGlobalDirFromGlobalRoot(globalRoot) ? globalRoot : null;
}
export function resolvePnpmGlobalDirFromGlobalRoot(globalRoot?: string | null): string | null {
const trimmed = globalRoot?.trim();
if (!trimmed) {
return null;
}
const normalized = path.resolve(trimmed);
if (path.basename(normalized) !== "node_modules") {
return null;
}
const layoutDir = path.dirname(normalized);
return /^\d+$/u.test(path.basename(layoutDir)) ? path.dirname(layoutDir) : null;
}
async function isPnpmGlobalPackageRoot(pkgRoot?: string | null): Promise<boolean> {
const globalRoot = inferPnpmGlobalRootFromPackageRoot(pkgRoot);
if (!globalRoot) {
return false;
}
const layoutDir = path.dirname(globalRoot);
if (!(await pathExists(path.join(globalRoot, ".modules.yaml")))) {
return false;
}
return (
(await pathExists(path.join(layoutDir, "pnpm-lock.yaml"))) ||
(await pathExists(path.join(layoutDir, "package.json")))
);
}
function resolvePreferredGlobalManagerCommand(
manager: GlobalInstallManager,
pkgRoot?: string | null,
@@ -558,10 +621,15 @@ export async function resolveGlobalInstallTarget(params: {
params.timeoutMs,
params.pkgRoot,
);
const pkgRootGlobalRoot =
command.manager === "pnpm" && (await isPnpmGlobalPackageRoot(params.pkgRoot))
? inferPnpmGlobalRootFromPackageRoot(params.pkgRoot)
: null;
const targetGlobalRoot = pkgRootGlobalRoot ?? globalRoot;
return {
...command,
globalRoot,
packageRoot: globalRoot ? path.join(globalRoot, PRIMARY_PACKAGE_NAME) : null,
globalRoot: targetGlobalRoot,
packageRoot: targetGlobalRoot ? path.join(targetGlobalRoot, PRIMARY_PACKAGE_NAME) : null,
};
}
@@ -599,6 +667,10 @@ export async function detectGlobalInstallManagerForRoot(
}
}
if (await isPnpmGlobalPackageRoot(pkgRoot)) {
return "pnpm";
}
const bunGlobalRoot = resolveBunGlobalRoot();
const bunGlobalReal = await tryRealpath(bunGlobalRoot);
for (const name of ALL_PACKAGE_NAMES) {
@@ -649,7 +721,13 @@ export function globalInstallArgs(
): string[] {
const resolved = normalizeGlobalInstallCommand(managerOrCommand, pkgRoot);
if (resolved.manager === "pnpm") {
return [resolved.command, "add", "-g", spec];
return [
resolved.command,
"add",
"-g",
...(installPrefix ? ["--global-dir", installPrefix] : []),
spec,
];
}
if (resolved.manager === "bun") {
return [resolved.command, "add", "-g", spec];