fix: keep bundled runtime deps in managed stage

This commit is contained in:
Peter Steinberger
2026-04-25 21:07:36 +01:00
parent b40df76c18
commit afabbc01b2
5 changed files with 54 additions and 10 deletions

View File

@@ -61,6 +61,12 @@ Docs: https://docs.openclaw.ai
- iOS/macOS Talk Mode: allow `talk.speechLocale` to set the speech
recognition locale for non-English voice conversations. Fixes #44688.
- Plugins/providers: honor explicit plugin candidate lists instead of reading a
persisted registry snapshot from local state, keeping candidate-scoped
provider discovery hermetic.
- Plugins/doctor: keep bundled plugin runtime-dependency repairs inside the
managed OpenClaw stage even when user npm prefix/global config points npm at
`$HOME/node_modules`. Fixes #71730.
- Plugins/Voice Call: treat missing provider credentials as setup-incomplete
during Gateway startup and log the missing keys as a warning instead of a
runtime startup error, while keeping explicit command/tool errors when used. Thanks

View File

@@ -74,6 +74,10 @@ ReadWritePaths=/var/lib/openclaw /home/openclaw/.openclaw /tmp
If `OPENCLAW_PLUGIN_STAGE_DIR` is not set, OpenClaw uses `$STATE_DIRECTORY` when
systemd provides it, then falls back to `~/.openclaw/plugin-runtime-deps`.
The repair step treats that stage as an OpenClaw-owned local package root and
ignores user npm prefix/global settings, so global-install npm config does not
redirect bundled plugin dependencies into `~/node_modules` or the global package
tree.
### Bundled plugin runtime dependencies

View File

@@ -65,8 +65,12 @@ describe("resolveBundledRuntimeDepsNpmRunner", () => {
{
PATH: "/usr/bin:/bin",
NPM_CONFIG_CACHE: "/Users/alice/.npm-uppercase",
NPM_CONFIG_GLOBAL: "true",
NPM_CONFIG_LOCATION: "global",
NPM_CONFIG_PREFIX: "/Users/alice",
npm_config_cache: "/Users/alice/.npm",
npm_config_global: "true",
npm_config_location: "global",
npm_config_prefix: "/opt/homebrew",
},
{ cacheDir: "/opt/openclaw/runtime-cache" },
@@ -170,6 +174,7 @@ describe("installBundledRuntimeDeps", () => {
});
it("uses the npm cmd shim on Windows", () => {
const installRoot = makeTempDir();
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
vi.spyOn(fs, "existsSync").mockImplementation(
(candidate) => candidate === "C:\\node\\node_modules\\npm\\bin\\npm-cli.js",
@@ -184,7 +189,7 @@ describe("installBundledRuntimeDeps", () => {
});
installBundledRuntimeDeps({
installRoot: "C:\\openclaw",
installRoot,
missingSpecs: ["acpx@0.5.3"],
env: {
npm_config_prefix: "C:\\prefix",
@@ -197,7 +202,7 @@ describe("installBundledRuntimeDeps", () => {
expect.any(String),
["C:\\node\\node_modules\\npm\\bin\\npm-cli.js", "install", "--ignore-scripts", "acpx@0.5.3"],
expect.objectContaining({
cwd: "C:\\openclaw",
cwd: installRoot,
env: expect.objectContaining({
npm_config_legacy_peer_deps: "true",
npm_config_package_lock: "false",
@@ -286,10 +291,20 @@ describe("installBundledRuntimeDeps", () => {
env: {
HOME: "/Users/alice",
NPM_CONFIG_CACHE: "/Users/alice/.npm-uppercase",
NPM_CONFIG_GLOBAL: "true",
NPM_CONFIG_LOCATION: "global",
NPM_CONFIG_PREFIX: "/Users/alice",
npm_config_cache: "/Users/alice/.npm",
npm_config_global: "true",
npm_config_location: "global",
npm_config_prefix: "/opt/homebrew",
},
});
expect(JSON.parse(fs.readFileSync(path.join(installRoot, "package.json"), "utf8"))).toEqual({
name: "openclaw-runtime-deps-install",
private: true,
});
expect(spawnSyncMock).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
@@ -307,6 +322,12 @@ describe("installBundledRuntimeDeps", () => {
expect.objectContaining({
env: expect.not.objectContaining({
NPM_CONFIG_CACHE: expect.any(String),
NPM_CONFIG_GLOBAL: expect.any(String),
NPM_CONFIG_LOCATION: expect.any(String),
NPM_CONFIG_PREFIX: expect.any(String),
npm_config_global: expect.any(String),
npm_config_location: expect.any(String),
npm_config_prefix: expect.any(String),
}),
}),
);

View File

@@ -723,6 +723,9 @@ function storeSourceCheckoutRuntimeDepsCache(params: {
function createNestedNpmInstallEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const nextEnv = { ...env };
delete nextEnv.NPM_CONFIG_CACHE;
delete nextEnv.NPM_CONFIG_GLOBAL;
delete nextEnv.NPM_CONFIG_LOCATION;
delete nextEnv.NPM_CONFIG_PREFIX;
delete nextEnv.npm_config_cache;
delete nextEnv.npm_config_global;
delete nextEnv.npm_config_location;
@@ -1126,6 +1129,14 @@ function shouldCleanBundledRuntimeDepsInstallExecutionRoot(params: {
return installExecutionRoot.startsWith(`${installRoot}${path.sep}`);
}
function writeBundledRuntimeDepsInstallManifest(installExecutionRoot: string): void {
fs.writeFileSync(
path.join(installExecutionRoot, "package.json"),
`${JSON.stringify({ name: "openclaw-runtime-deps-install", private: true }, null, 2)}\n`,
"utf8",
);
}
export function installBundledRuntimeDeps(params: {
installRoot: string;
installExecutionRoot?: string;
@@ -1144,13 +1155,11 @@ export function installBundledRuntimeDeps(params: {
try {
fs.mkdirSync(params.installRoot, { recursive: true });
fs.mkdirSync(installExecutionRoot, { recursive: true });
if (isolatedExecutionRoot) {
fs.writeFileSync(
path.join(installExecutionRoot, "package.json"),
`${JSON.stringify({ name: "openclaw-runtime-deps-install", private: true }, null, 2)}\n`,
"utf8",
);
}
// Always make npm see an OpenClaw-owned package root. The package-level
// doctor repair path installs directly in the external stage dir; without a
// manifest, npm can honor a user's global prefix config and write under
// $HOME/node_modules instead of our managed stage.
writeBundledRuntimeDepsInstallManifest(installExecutionRoot);
const installEnv = createBundledRuntimeDepsInstallEnv(params.env, {
cacheDir: path.join(installExecutionRoot, ".openclaw-npm-cache"),
});

View File

@@ -56,7 +56,11 @@ function sortedValues(values: Iterable<string>): string[] {
export function resolveInstalledPluginProviderContributionIds(
params: ResolveInstalledPluginProviderContributionIdsParams = {},
): string[] {
const index = params.index ?? loadPluginRegistrySnapshot(params);
const registryParams =
params.candidates && params.preferPersisted === undefined
? { ...params, preferPersisted: false }
: params;
const index = params.index ?? loadPluginRegistrySnapshot(registryParams);
return sortedValues(
listPluginContributionIds({
index,