diff --git a/CHANGELOG.md b/CHANGELOG.md index 876fe0797fa..06a0814ec05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/install/updating.md b/docs/install/updating.md index fd0f91f00df..03b06228eb1 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -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 diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index b6c7b1617a0..98dbe5e0553 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -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), }), }), ); diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index deb3a963486..7c430fc9c42 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -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"), }); diff --git a/src/plugins/provider-discovery.ts b/src/plugins/provider-discovery.ts index 7f68ee96275..3455676f40e 100644 --- a/src/plugins/provider-discovery.ts +++ b/src/plugins/provider-discovery.ts @@ -56,7 +56,11 @@ function sortedValues(values: Iterable): 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,