From 6956e8406d7c62b59a26e8b8fc44887870196f09 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 14:30:04 +0100 Subject: [PATCH] fix: honor profile plugin install roots --- CHANGELOG.md | 1 + src/cli/plugins-cli.install.test.ts | 123 +++++++++++++++++- src/cli/plugins-install-command.ts | 11 ++ .../channel-setup/plugin-install.test.ts | 41 +++++- src/commands/onboarding-plugin-install.ts | 2 + src/plugins/install-paths.ts | 11 +- src/plugins/install.ts | 7 +- 7 files changed, 189 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b672e44f86e..24b6010b87c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - Google Meet: route local Chrome joins through OpenClaw browser control instead of raw default Chrome, so agents use the configured OpenClaw browser profile when opening Meet. - Plugins/discovery: follow symlinked plugin directories in global and workspace plugin roots while keeping broken links ignored and existing package safety checks in place. Fixes #36754; carries forward #72695 and #63206. Thanks @Quackstro, @ming1523, and @xsfX20. +- Plugins/install: resolve plugin install destinations from the active profile state dir across CLI, ClawHub, marketplace, local path, and channel setup installs, so `openclaw --profile plugins install ...` no longer writes into the default profile. Fixes #69960; carries forward #69971. Thanks @FrancisLyman and @Sanjays2402. - Plugins/startup: reuse canonical realpath lookups throughout each plugin discovery pass, including package and manifest boundary checks, so Windows npm-global startups no longer repeat expensive path resolution for the same plugin roots. Fixes #65733. Thanks @welfo-beo. - Reply/link understanding: keep media and link preprocessing on stable runtime entrypoints and continue with raw message content if optional enrichment fails, so URL-bearing messages are no longer dropped after stale runtime chunk upgrades. Fixes #68466. Thanks @songshikang0111. - Discord: persist routed model-picker overrides when the hidden `/model` dispatch succeeds but the bound thread session store is still stale, including LM Studio suffixed model ids. Carries forward #61473. Thanks @Nanako0129. diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index 607d342a3fd..92ed192cbf1 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { installedPluginRoot } from "../../test/helpers/bundled-plugin-paths.js"; import type { OpenClawConfig } from "../config/config.js"; import { @@ -30,11 +30,18 @@ import { } from "./plugins-cli-test-helpers.js"; const CLI_STATE_ROOT = "/tmp/openclaw-state"; +const ORIGINAL_OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR; +const PROFILE_STATE_ROOT = "/tmp/openclaw-ledger-profile"; function cliInstallPath(pluginId: string): string { return installedPluginRoot(CLI_STATE_ROOT, pluginId); } +function useProfileExtensionsDir(): string { + process.env.OPENCLAW_STATE_DIR = PROFILE_STATE_ROOT; + return path.join(PROFILE_STATE_ROOT, "extensions"); +} + function createEnabledPluginConfig(pluginId: string): OpenClawConfig { return { plugins: { @@ -227,6 +234,14 @@ describe("plugins cli install", () => { resetPluginsCliTestState(); }); + afterEach(() => { + if (ORIGINAL_OPENCLAW_STATE_DIR === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = ORIGINAL_OPENCLAW_STATE_DIR; + } + }); + it("shows the force overwrite option in install help", async () => { const { Command } = await import("commander"); const { registerPluginsCli } = await import("./plugins-cli.js"); @@ -275,6 +290,22 @@ describe("plugins cli install", () => { expect(writeConfigFile).not.toHaveBeenCalled(); }); + it("passes the active profile extensions dir to marketplace installs", async () => { + const extensionsDir = useProfileExtensionsDir(); + + await expect( + runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo"]), + ).rejects.toThrow("__exit__:1"); + + expect(installPluginFromMarketplace).toHaveBeenCalledWith( + expect.objectContaining({ + extensionsDir, + marketplace: "local/repo", + plugin: "alpha", + }), + ); + }); + it("fails closed for unrelated invalid config before installer side effects", async () => { const invalidConfigErr = new Error("config invalid"); (invalidConfigErr as { code?: string }).code = "INVALID_CONFIG"; @@ -421,6 +452,37 @@ describe("plugins cli install", () => { expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); }); + it("passes the active profile extensions dir to ClawHub installs", async () => { + const extensionsDir = useProfileExtensionsDir(); + const cfg = createEmptyPluginConfig(); + const enabledCfg = createEnabledPluginConfig("demo"); + + loadConfig.mockReturnValue(cfg); + parseClawHubPluginSpec.mockReturnValue({ name: "demo" }); + installPluginFromClawHub.mockResolvedValue( + createClawHubInstallResult({ + pluginId: "demo", + packageName: "demo", + version: "1.2.3", + channel: "official", + }), + ); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledCfg, + warnings: [], + }); + + await runPluginsCommand(["plugins", "install", "clawhub:demo"]); + + expect(installPluginFromClawHub).toHaveBeenCalledWith( + expect.objectContaining({ + extensionsDir, + spec: "clawhub:demo", + }), + ); + }); + it("does not persist incomplete config entries for config-gated bundled installs", async () => { const cfg = { plugins: { @@ -664,6 +726,30 @@ describe("plugins cli install", () => { expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg); }); + it("passes the active profile extensions dir to npm installs", async () => { + const extensionsDir = useProfileExtensionsDir(); + const cfg = createEmptyPluginConfig(); + const enabledCfg = createEnabledPluginConfig("demo"); + + loadConfig.mockReturnValue(cfg); + installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("demo")); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + recordPluginInstall.mockReturnValue(enabledCfg); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledCfg, + warnings: [], + }); + + await runPluginsCommand(["plugins", "install", "npm:demo"]); + + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + extensionsDir, + spec: "demo", + }), + ); + }); + it("passes npm: prefix installs through npm options without ClawHub lookup", async () => { const cfg = createEmptyPluginConfig(); const enabledCfg = createEnabledPluginConfig("demo"); @@ -903,6 +989,41 @@ describe("plugins cli install", () => { }), ); }); + + it("passes the active profile extensions dir to local path installs", async () => { + const extensionsDir = useProfileExtensionsDir(); + const localPluginDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-local-plugin-")); + const cfg = createEmptyPluginConfig(); + const enabledCfg = createEnabledPluginConfig("demo"); + + loadConfig.mockReturnValue(cfg); + installPluginFromPath.mockResolvedValue({ + ok: true, + pluginId: "demo", + targetDir: path.join(extensionsDir, "demo"), + version: "1.2.3", + extensions: ["./dist/index.js"], + }); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + recordPluginInstall.mockReturnValue(enabledCfg); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledCfg, + warnings: [], + }); + + try { + await runPluginsCommand(["plugins", "install", localPluginDir]); + } finally { + fs.rmSync(localPluginDir, { recursive: true, force: true }); + } + + expect(installPluginFromPath).toHaveBeenCalledWith( + expect.objectContaining({ + extensionsDir, + path: localPluginDir, + }), + ); + }); it("passes force through as overwrite mode for npm installs", async () => { primeNpmPluginFallback(); diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index 99c00f7a91a..bf86eefc450 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -8,6 +8,7 @@ import { parseClawHubPluginSpec } from "../infra/clawhub.js"; import { formatErrorMessage } from "../infra/errors.js"; import { type BundledPluginSource, findBundledPluginSource } from "../plugins/bundled-sources.js"; import { installPluginFromClawHub } from "../plugins/clawhub.js"; +import { resolveDefaultPluginExtensionsDir } from "../plugins/install-paths.js"; import type { InstallSafetyOverrides } from "../plugins/install-security-scan.js"; import { PLUGIN_INSTALL_ERROR_CODE, @@ -271,11 +272,13 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: { pin?: boolean; safetyOverrides: InstallSafetyOverrides; allowBundledFallback: boolean; + extensionsDir: string; }): Promise<{ ok: true } | { ok: false }> { const result = await installPluginFromNpmSpec({ ...params.safetyOverrides, mode: params.installMode, spec: params.spec, + extensionsDir: params.extensionsDir, logger: createPluginInstallLogger(), }); if (!result.ok) { @@ -468,6 +471,7 @@ export async function runPluginInstallCommand(params: { const cfg = snapshot.config; const installMode = resolveInstallMode(opts.force); const safetyOverrides = resolveInstallSafetyOverrides(opts); + const extensionsDir = resolveDefaultPluginExtensionsDir(); if (opts.marketplace) { const result = await installPluginFromMarketplace({ @@ -475,6 +479,7 @@ export async function runPluginInstallCommand(params: { marketplace: opts.marketplace, mode: installMode, plugin: raw, + extensionsDir, logger: createPluginInstallLogger(), }); if (!result.ok) { @@ -508,6 +513,7 @@ export async function runPluginInstallCommand(params: { mode: installMode, path: resolved, dryRun: true, + extensionsDir, logger: createPluginInstallLogger(), }); if (!probe.ok) { @@ -561,6 +567,7 @@ export async function runPluginInstallCommand(params: { ...safetyOverrides, mode: installMode, path: resolved, + extensionsDir, logger: createPluginInstallLogger(), }); if (!result.ok) { @@ -616,6 +623,7 @@ export async function runPluginInstallCommand(params: { pin: opts.pin, safetyOverrides, allowBundledFallback: false, + extensionsDir, }); if (!npmPrefixResult.ok) { return defaultRuntime.exit(1); @@ -659,6 +667,7 @@ export async function runPluginInstallCommand(params: { ...safetyOverrides, mode: installMode, spec: raw, + extensionsDir, logger: createPluginInstallLogger(), }); if (!result.ok) { @@ -692,6 +701,7 @@ export async function runPluginInstallCommand(params: { ...safetyOverrides, mode: installMode, spec: preferredClawHubSpec, + extensionsDir, logger: createPluginInstallLogger(), }); if (clawhubResult.ok) { @@ -727,6 +737,7 @@ export async function runPluginInstallCommand(params: { pin: opts.pin, safetyOverrides, allowBundledFallback: true, + extensionsDir, }); if (!npmResult.ok) { return defaultRuntime.exit(1); diff --git a/src/commands/channel-setup/plugin-install.test.ts b/src/commands/channel-setup/plugin-install.test.ts index 656298a5002..105b8f9fd75 100644 --- a/src/commands/channel-setup/plugin-install.test.ts +++ b/src/commands/channel-setup/plugin-install.test.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { bundledPluginRoot, bundledPluginRootAt, @@ -120,6 +120,7 @@ const bundledChatNpmSpec = "@openclaw/bundled-chat@1.2.3"; const bundledChatIntegrity = "sha512-bundled-chat"; const bundledChatForkNpmSpec = "@vendor/bundled-chat-fork@1.2.3"; const bundledChatForkIntegrity = "sha512-vendor-bundled-chat-fork"; +const ORIGINAL_OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR; const baseEntry: ChannelPluginCatalogEntry = { id: "bundled-chat", @@ -240,6 +241,14 @@ beforeEach(() => { setActivePluginRegistry(createEmptyPluginRegistry()); }); +afterEach(() => { + if (ORIGINAL_OPENCLAW_STATE_DIR === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = ORIGINAL_OPENCLAW_STATE_DIR; + } +}); + function mockRepoLocalPathExists() { execFileSync.mockImplementation((command: string, args: string[]) => { expect(command).toBe("git"); @@ -352,6 +361,36 @@ describe("ensureChannelSetupPluginInstalled", () => { ); }); + it("installs npm channel plugins into the active profile extensions dir", async () => { + const runtime = makeRuntime(); + const prompter = makePrompter({ + select: vi.fn(async () => "npm") as WizardPrompter["select"], + }); + const profileStateDir = "/tmp/openclaw-ledger-channel"; + process.env.OPENCLAW_STATE_DIR = profileStateDir; + vi.mocked(fs.existsSync).mockReturnValue(false); + installPluginFromNpmSpec.mockResolvedValue({ + ok: true, + pluginId: "bundled-chat", + targetDir: path.join(profileStateDir, "extensions", "bundled-chat"), + extensions: [], + }); + + await ensureChannelSetupPluginInstalled({ + cfg: {}, + entry: baseEntry, + prompter, + runtime, + }); + + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + extensionsDir: path.join(profileStateDir, "extensions"), + spec: bundledChatNpmSpec, + }), + ); + }); + it("uses local path when selected", async () => { const runtime = makeRuntime(); const prompter = makePrompter({ diff --git a/src/commands/onboarding-plugin-install.ts b/src/commands/onboarding-plugin-install.ts index 4c61f48b061..339daf4c9e9 100644 --- a/src/commands/onboarding-plugin-install.ts +++ b/src/commands/onboarding-plugin-install.ts @@ -8,6 +8,7 @@ import { resolveBundledPluginSources, } from "../plugins/bundled-sources.js"; import { enablePluginInConfig, type PluginEnableResult } from "../plugins/enable.js"; +import { resolveDefaultPluginExtensionsDir } from "../plugins/install-paths.js"; import { installPluginFromNpmSpec } from "../plugins/install.js"; import { buildNpmResolutionInstallFields, recordPluginInstall } from "../plugins/installs.js"; import type { PluginPackageInstall } from "../plugins/manifest.js"; @@ -393,6 +394,7 @@ async function installPluginFromNpmSpecWithProgress(params: { spec: params.npmSpec, timeoutMs: ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS, expectedIntegrity: params.entry.install.expectedIntegrity, + extensionsDir: resolveDefaultPluginExtensionsDir(), logger: { info: updateProgress, warn: (message) => { diff --git a/src/plugins/install-paths.ts b/src/plugins/install-paths.ts index 56d5db79c87..1a2b0d75a29 100644 --- a/src/plugins/install-paths.ts +++ b/src/plugins/install-paths.ts @@ -5,7 +5,7 @@ import { safePathSegmentHashed, unscopedPackageName, } from "../infra/install-safe-path.js"; -import { CONFIG_DIR, resolveUserPath } from "../utils.js"; +import { resolveConfigDir, resolveUserPath } from "../utils.js"; export function safePluginInstallFileName(input: string): string { return safeDirName(input); @@ -73,10 +73,17 @@ export function matchesExpectedPluginId(params: { ); } +export function resolveDefaultPluginExtensionsDir( + env: NodeJS.ProcessEnv = process.env, + homedir?: () => string, +): string { + return path.join(resolveConfigDir(env, homedir), "extensions"); +} + export function resolvePluginInstallDir(pluginId: string, extensionsDir?: string): string { const extensionsBase = extensionsDir ? resolveUserPath(extensionsDir) - : path.join(CONFIG_DIR, "extensions"); + : resolveDefaultPluginExtensionsDir(); const pluginIdError = validatePluginId(pluginId); if (pluginIdError) { throw new Error(pluginIdError); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index cb226e7c755..71dee0fc087 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -4,10 +4,11 @@ import { packageNameMatchesId } from "../infra/install-safe-path.js"; import { type NpmIntegrityDrift, type NpmSpecResolution } from "../infra/install-source-utils.js"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; -import { CONFIG_DIR, resolveUserPath } from "../utils.js"; +import { resolveUserPath } from "../utils.js"; import { encodePluginInstallDirName, matchesExpectedPluginId, + resolveDefaultPluginExtensionsDir, safePluginInstallFileName, validatePluginId, } from "./install-paths.js"; @@ -389,7 +390,7 @@ async function resolvePluginInstallTarget(params: { }): Promise<{ ok: true; targetDir: string } | { ok: false; error: string }> { const extensionsDir = params.extensionsDir ? resolveUserPath(params.extensionsDir) - : path.join(CONFIG_DIR, "extensions"); + : resolveDefaultPluginExtensionsDir(); return await params.runtime.resolveCanonicalInstallTarget({ baseDir: extensionsDir, id: params.pluginId, @@ -874,7 +875,7 @@ export async function installPluginFromFile(params: { const extensionsDir = params.extensionsDir ? resolveUserPath(params.extensionsDir) - : path.join(CONFIG_DIR, "extensions"); + : resolveDefaultPluginExtensionsDir(); await fs.mkdir(extensionsDir, { recursive: true }); const base = path.basename(filePath, path.extname(filePath));