fix: honor profile plugin install roots

This commit is contained in:
Peter Steinberger
2026-04-27 14:30:04 +01:00
parent f88c330657
commit 6956e8406d
7 changed files with 189 additions and 7 deletions

View File

@@ -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 <name> 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.

View File

@@ -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();

View File

@@ -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);

View File

@@ -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({

View File

@@ -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) => {

View File

@@ -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);

View File

@@ -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));