mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:40:43 +00:00
fix: honor profile plugin install roots
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user