mirror of https://github.com/openclaw/openclaw.git
Plugins: fix env-aware root resolution and caching (#44046)
Merged via squash.
Prepared head SHA: 6e8852a188
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
parent
688e3f0863
commit
e6897c800b
|
|
@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai
|
|||
- Doctor/gateway service audit: earlier groundwork for this fix landed in the superseded #28338 branch. Thanks @realriphub.
|
||||
- Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in `/models` button validation. (#40105) Thanks @avirweb.
|
||||
- Mattermost/reply media delivery: pass agent-scoped `mediaLocalRoots` through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666.
|
||||
- Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process `HOME`/`OPENCLAW_HOME` changes no longer reuse stale plugin state or misreport `~/...` plugins as untracked. (#44046) thanks @gumadeiras.
|
||||
|
||||
## 2026.3.11
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { MANIFEST_KEY } from "../../compat/legacy-names.js";
|
|||
import { discoverOpenClawPlugins } from "../../plugins/discovery.js";
|
||||
import type { OpenClawPackageManifest } from "../../plugins/manifest.js";
|
||||
import type { PluginOrigin } from "../../plugins/types.js";
|
||||
import { CONFIG_DIR, isRecord, resolveUserPath } from "../../utils.js";
|
||||
import { isRecord, resolveConfigDir, resolveUserPath } from "../../utils.js";
|
||||
import type { ChannelMeta } from "./types.js";
|
||||
|
||||
export type ChannelUiMetaEntry = {
|
||||
|
|
@ -36,6 +36,7 @@ export type ChannelPluginCatalogEntry = {
|
|||
type CatalogOptions = {
|
||||
workspaceDir?: string;
|
||||
catalogPaths?: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
const ORIGIN_PRIORITY: Record<PluginOrigin, number> = {
|
||||
|
|
@ -51,12 +52,6 @@ type ExternalCatalogEntry = {
|
|||
description?: string;
|
||||
} & Partial<Record<ManifestKey, OpenClawPackageManifest>>;
|
||||
|
||||
const DEFAULT_CATALOG_PATHS = [
|
||||
path.join(CONFIG_DIR, "mpm", "plugins.json"),
|
||||
path.join(CONFIG_DIR, "mpm", "catalog.json"),
|
||||
path.join(CONFIG_DIR, "plugins", "catalog.json"),
|
||||
];
|
||||
|
||||
const ENV_CATALOG_PATHS = ["OPENCLAW_PLUGIN_CATALOG_PATHS", "OPENCLAW_MPM_CATALOG_PATHS"];
|
||||
|
||||
type ManifestKey = typeof MANIFEST_KEY;
|
||||
|
|
@ -87,24 +82,35 @@ function splitEnvPaths(value: string): string[] {
|
|||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveDefaultCatalogPaths(env: NodeJS.ProcessEnv): string[] {
|
||||
const configDir = resolveConfigDir(env);
|
||||
return [
|
||||
path.join(configDir, "mpm", "plugins.json"),
|
||||
path.join(configDir, "mpm", "catalog.json"),
|
||||
path.join(configDir, "plugins", "catalog.json"),
|
||||
];
|
||||
}
|
||||
|
||||
function resolveExternalCatalogPaths(options: CatalogOptions): string[] {
|
||||
if (options.catalogPaths && options.catalogPaths.length > 0) {
|
||||
return options.catalogPaths.map((entry) => entry.trim()).filter(Boolean);
|
||||
}
|
||||
const env = options.env ?? process.env;
|
||||
for (const key of ENV_CATALOG_PATHS) {
|
||||
const raw = process.env[key];
|
||||
const raw = env[key];
|
||||
if (raw && raw.trim()) {
|
||||
return splitEnvPaths(raw);
|
||||
}
|
||||
}
|
||||
return DEFAULT_CATALOG_PATHS;
|
||||
return resolveDefaultCatalogPaths(env);
|
||||
}
|
||||
|
||||
function loadExternalCatalogEntries(options: CatalogOptions): ExternalCatalogEntry[] {
|
||||
const paths = resolveExternalCatalogPaths(options);
|
||||
const env = options.env ?? process.env;
|
||||
const entries: ExternalCatalogEntry[] = [];
|
||||
for (const rawPath of paths) {
|
||||
const resolved = resolveUserPath(rawPath);
|
||||
const resolved = resolveUserPath(rawPath, env);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -259,7 +265,10 @@ export function buildChannelUiCatalog(
|
|||
export function listChannelPluginCatalogEntries(
|
||||
options: CatalogOptions = {},
|
||||
): ChannelPluginCatalogEntry[] {
|
||||
const discovery = discoverOpenClawPlugins({ workspaceDir: options.workspaceDir });
|
||||
const discovery = discoverOpenClawPlugins({
|
||||
workspaceDir: options.workspaceDir,
|
||||
env: options.env,
|
||||
});
|
||||
const resolved = new Map<string, { entry: ChannelPluginCatalogEntry; priority: number }>();
|
||||
|
||||
for (const candidate of discovery.candidates) {
|
||||
|
|
|
|||
|
|
@ -153,6 +153,82 @@ describe("channel plugin catalog", () => {
|
|||
);
|
||||
expect(ids).toContain("demo-channel");
|
||||
});
|
||||
|
||||
it("uses the provided env for external catalog path resolution", () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-home-"));
|
||||
const catalogPath = path.join(home, "catalog.json");
|
||||
fs.writeFileSync(
|
||||
catalogPath,
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
name: "@openclaw/env-demo-channel",
|
||||
openclaw: {
|
||||
channel: {
|
||||
id: "env-demo-channel",
|
||||
label: "Env Demo Channel",
|
||||
selectionLabel: "Env Demo Channel",
|
||||
docsPath: "/channels/env-demo-channel",
|
||||
blurb: "Env demo entry",
|
||||
order: 1000,
|
||||
},
|
||||
install: {
|
||||
npmSpec: "@openclaw/env-demo-channel",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const ids = listChannelPluginCatalogEntries({
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_PLUGIN_CATALOG_PATHS: "~/catalog.json",
|
||||
HOME: home,
|
||||
},
|
||||
}).map((entry) => entry.id);
|
||||
|
||||
expect(ids).toContain("env-demo-channel");
|
||||
});
|
||||
|
||||
it("uses the provided env for default catalog paths", () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-state-"));
|
||||
const catalogPath = path.join(stateDir, "plugins", "catalog.json");
|
||||
fs.mkdirSync(path.dirname(catalogPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
catalogPath,
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
name: "@openclaw/default-env-demo",
|
||||
openclaw: {
|
||||
channel: {
|
||||
id: "default-env-demo",
|
||||
label: "Default Env Demo",
|
||||
selectionLabel: "Default Env Demo",
|
||||
docsPath: "/channels/default-env-demo",
|
||||
blurb: "Default env demo entry",
|
||||
},
|
||||
install: {
|
||||
npmSpec: "@openclaw/default-env-demo",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const ids = listChannelPluginCatalogEntries({
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
CLAWDBOT_STATE_DIR: undefined,
|
||||
},
|
||||
}).map((entry) => entry.id);
|
||||
|
||||
expect(ids).toContain("default-env-demo");
|
||||
});
|
||||
});
|
||||
|
||||
const emptyRegistry = createTestRegistry([]);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,41 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { clearPluginDiscoveryCache } from "../plugins/discovery.js";
|
||||
import {
|
||||
clearPluginManifestRegistryCache,
|
||||
type PluginManifestRegistry,
|
||||
} from "../plugins/manifest-registry.js";
|
||||
import { validateConfigObject } from "./config.js";
|
||||
import { applyPluginAutoEnable } from "./plugin-auto-enable.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeTempDir() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-auto-enable-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function writePluginManifestFixture(params: { rootDir: string; id: string; channels: string[] }) {
|
||||
fs.mkdirSync(params.rootDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(params.rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
id: params.id,
|
||||
channels: params.channels,
|
||||
configSchema: { type: "object" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(path.join(params.rootDir, "index.ts"), "export default {}", "utf-8");
|
||||
}
|
||||
|
||||
/** Helper to build a minimal PluginManifestRegistry for testing. */
|
||||
function makeRegistry(plugins: Array<{ id: string; channels: string[] }>): PluginManifestRegistry {
|
||||
return {
|
||||
|
|
@ -66,6 +99,14 @@ function applyWithBluebubblesImessageConfig(extra?: {
|
|||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
clearPluginDiscoveryCache();
|
||||
clearPluginManifestRegistryCache();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("applyPluginAutoEnable", () => {
|
||||
it("auto-enables built-in channels and appends to existing allowlist", () => {
|
||||
const result = applyWithSlackConfig({ plugins: { allow: ["telegram"] } });
|
||||
|
|
@ -158,6 +199,79 @@ describe("applyPluginAutoEnable", () => {
|
|||
expect(result.changes.join("\n")).toContain("IRC configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("uses the provided env when loading plugin manifests automatically", () => {
|
||||
const stateDir = makeTempDir();
|
||||
const pluginDir = path.join(stateDir, "extensions", "apn-channel");
|
||||
writePluginManifestFixture({
|
||||
rootDir: pluginDir,
|
||||
id: "apn-channel",
|
||||
channels: ["apn"],
|
||||
});
|
||||
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: { apn: { someKey: "value" } },
|
||||
},
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
CLAWDBOT_STATE_DIR: undefined,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.["apn-channel"]?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.apn).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses env-scoped catalog metadata for preferOver auto-enable decisions", () => {
|
||||
const stateDir = makeTempDir();
|
||||
const catalogPath = path.join(stateDir, "plugins", "catalog.json");
|
||||
fs.mkdirSync(path.dirname(catalogPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
catalogPath,
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
name: "@openclaw/env-secondary",
|
||||
openclaw: {
|
||||
channel: {
|
||||
id: "env-secondary",
|
||||
label: "Env Secondary",
|
||||
selectionLabel: "Env Secondary",
|
||||
docsPath: "/channels/env-secondary",
|
||||
blurb: "Env secondary entry",
|
||||
preferOver: ["env-primary"],
|
||||
},
|
||||
install: {
|
||||
npmSpec: "@openclaw/env-secondary",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: {
|
||||
"env-primary": { enabled: true },
|
||||
"env-secondary": { enabled: true },
|
||||
},
|
||||
},
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
CLAWDBOT_STATE_DIR: undefined,
|
||||
},
|
||||
manifestRegistry: makeRegistry([]),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.["env-secondary"]?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.["env-primary"]?.enabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it("auto-enables provider auth plugins when profiles exist", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
|
|
@ -311,5 +425,28 @@ describe("applyPluginAutoEnable", () => {
|
|||
expect(result.config.channels?.imessage?.enabled).toBe(true);
|
||||
expect(result.changes.join("\n")).toContain("iMessage configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("uses the provided env when loading installed plugin manifests", () => {
|
||||
const stateDir = makeTempDir();
|
||||
const pluginDir = path.join(stateDir, "extensions", "apn-channel");
|
||||
writePluginManifestFixture({
|
||||
rootDir: pluginDir,
|
||||
id: "apn-channel",
|
||||
channels: ["apn"],
|
||||
});
|
||||
|
||||
const result = applyPluginAutoEnable({
|
||||
config: makeApnChannelConfig(),
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
CLAWDBOT_STATE_DIR: undefined,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.["apn-channel"]?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.apn).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,13 +27,6 @@ export type PluginAutoEnableResult = {
|
|||
changes: string[];
|
||||
};
|
||||
|
||||
const CHANNEL_PLUGIN_IDS = Array.from(
|
||||
new Set([
|
||||
...listChatChannels().map((meta) => meta.id),
|
||||
...listChannelPluginCatalogEntries().map((entry) => entry.id),
|
||||
]),
|
||||
);
|
||||
|
||||
const PROVIDER_PLUGIN_IDS: Array<{ pluginId: string; providerId: string }> = [
|
||||
{ pluginId: "google-gemini-cli-auth", providerId: "google-gemini-cli" },
|
||||
{ pluginId: "qwen-portal-auth", providerId: "qwen-portal" },
|
||||
|
|
@ -315,8 +308,17 @@ function resolvePluginIdForChannel(
|
|||
return channelToPluginId.get(channelId) ?? channelId;
|
||||
}
|
||||
|
||||
function collectCandidateChannelIds(cfg: OpenClawConfig): string[] {
|
||||
const channelIds = new Set<string>(CHANNEL_PLUGIN_IDS);
|
||||
function listKnownChannelPluginIds(env: NodeJS.ProcessEnv): string[] {
|
||||
return Array.from(
|
||||
new Set([
|
||||
...listChatChannels().map((meta) => meta.id),
|
||||
...listChannelPluginCatalogEntries({ env }).map((entry) => entry.id),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function collectCandidateChannelIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): string[] {
|
||||
const channelIds = new Set<string>(listKnownChannelPluginIds(env));
|
||||
const configuredChannels = cfg.channels as Record<string, unknown> | undefined;
|
||||
if (!configuredChannels || typeof configuredChannels !== "object") {
|
||||
return Array.from(channelIds);
|
||||
|
|
@ -339,7 +341,7 @@ function resolveConfiguredPlugins(
|
|||
const changes: PluginEnableChange[] = [];
|
||||
// Build reverse map: channel ID → plugin ID from installed plugin manifests.
|
||||
const channelToPluginId = buildChannelToPluginIdMap(registry);
|
||||
for (const channelId of collectCandidateChannelIds(cfg)) {
|
||||
for (const channelId of collectCandidateChannelIds(cfg, env)) {
|
||||
const pluginId = resolvePluginIdForChannel(channelId, channelToPluginId);
|
||||
if (isChannelConfigured(cfg, channelId, env)) {
|
||||
changes.push({ pluginId, reason: `${channelId} configured` });
|
||||
|
|
@ -390,12 +392,12 @@ function isPluginDenied(cfg: OpenClawConfig, pluginId: string): boolean {
|
|||
return Array.isArray(deny) && deny.includes(pluginId);
|
||||
}
|
||||
|
||||
function resolvePreferredOverIds(pluginId: string): string[] {
|
||||
function resolvePreferredOverIds(pluginId: string, env: NodeJS.ProcessEnv): string[] {
|
||||
const normalized = normalizeChatChannelId(pluginId);
|
||||
if (normalized) {
|
||||
return getChatChannelMeta(normalized).preferOver ?? [];
|
||||
}
|
||||
const catalogEntry = getChannelPluginCatalogEntry(pluginId);
|
||||
const catalogEntry = getChannelPluginCatalogEntry(pluginId, { env });
|
||||
return catalogEntry?.meta.preferOver ?? [];
|
||||
}
|
||||
|
||||
|
|
@ -403,6 +405,7 @@ function shouldSkipPreferredPluginAutoEnable(
|
|||
cfg: OpenClawConfig,
|
||||
entry: PluginEnableChange,
|
||||
configured: PluginEnableChange[],
|
||||
env: NodeJS.ProcessEnv,
|
||||
): boolean {
|
||||
for (const other of configured) {
|
||||
if (other.pluginId === entry.pluginId) {
|
||||
|
|
@ -414,7 +417,7 @@ function shouldSkipPreferredPluginAutoEnable(
|
|||
if (isPluginExplicitlyDisabled(cfg, other.pluginId)) {
|
||||
continue;
|
||||
}
|
||||
const preferOver = resolvePreferredOverIds(other.pluginId);
|
||||
const preferOver = resolvePreferredOverIds(other.pluginId, env);
|
||||
if (preferOver.includes(entry.pluginId)) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -477,7 +480,8 @@ export function applyPluginAutoEnable(params: {
|
|||
manifestRegistry?: PluginManifestRegistry;
|
||||
}): PluginAutoEnableResult {
|
||||
const env = params.env ?? process.env;
|
||||
const registry = params.manifestRegistry ?? loadPluginManifestRegistry({ config: params.config });
|
||||
const registry =
|
||||
params.manifestRegistry ?? loadPluginManifestRegistry({ config: params.config, env });
|
||||
const configured = resolveConfiguredPlugins(params.config, env, registry);
|
||||
if (configured.length === 0) {
|
||||
return { config: params.config, changes: [] };
|
||||
|
|
@ -498,7 +502,7 @@ export function applyPluginAutoEnable(params: {
|
|||
if (isPluginExplicitlyDisabled(next, entry.pluginId)) {
|
||||
continue;
|
||||
}
|
||||
if (shouldSkipPreferredPluginAutoEnable(next, entry, configured)) {
|
||||
if (shouldSkipPreferredPluginAutoEnable(next, entry, configured, env)) {
|
||||
continue;
|
||||
}
|
||||
const allow = next.plugins?.allow;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
|
||||
export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
||||
const override = env.OPENCLAW_BUNDLED_PLUGINS_DIR?.trim();
|
||||
if (override) {
|
||||
return override;
|
||||
return resolveUserPath(override, env);
|
||||
}
|
||||
|
||||
// bun --compile: ship a sibling `extensions/` next to the executable.
|
||||
|
|
|
|||
|
|
@ -103,6 +103,34 @@ describe("bundled plugin sources", () => {
|
|||
expect(missing).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forwards an explicit env to bundled discovery helpers", () => {
|
||||
discoverOpenClawPluginsMock.mockReturnValue({
|
||||
candidates: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
const env = { HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv;
|
||||
|
||||
resolveBundledPluginSources({
|
||||
workspaceDir: "/workspace",
|
||||
env,
|
||||
});
|
||||
findBundledPluginSource({
|
||||
lookup: { kind: "pluginId", value: "feishu" },
|
||||
workspaceDir: "/workspace",
|
||||
env,
|
||||
});
|
||||
|
||||
expect(discoverOpenClawPluginsMock).toHaveBeenNthCalledWith(1, {
|
||||
workspaceDir: "/workspace",
|
||||
env,
|
||||
});
|
||||
expect(discoverOpenClawPluginsMock).toHaveBeenNthCalledWith(2, {
|
||||
workspaceDir: "/workspace",
|
||||
env,
|
||||
});
|
||||
});
|
||||
|
||||
it("finds bundled source by plugin id", () => {
|
||||
discoverOpenClawPluginsMock.mockReturnValue({
|
||||
candidates: [
|
||||
|
|
|
|||
|
|
@ -32,8 +32,13 @@ export function findBundledPluginSourceInMap(params: {
|
|||
|
||||
export function resolveBundledPluginSources(params: {
|
||||
workspaceDir?: string;
|
||||
/** Use an explicit env when bundled roots should resolve independently from process.env. */
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Map<string, BundledPluginSource> {
|
||||
const discovery = discoverOpenClawPlugins({ workspaceDir: params.workspaceDir });
|
||||
const discovery = discoverOpenClawPlugins({
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
});
|
||||
const bundled = new Map<string, BundledPluginSource>();
|
||||
|
||||
for (const candidate of discovery.candidates) {
|
||||
|
|
@ -67,8 +72,13 @@ export function resolveBundledPluginSources(params: {
|
|||
export function findBundledPluginSource(params: {
|
||||
lookup: BundledPluginLookup;
|
||||
workspaceDir?: string;
|
||||
/** Use an explicit env when bundled roots should resolve independently from process.env. */
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): BundledPluginSource | undefined {
|
||||
const bundled = resolveBundledPluginSources({ workspaceDir: params.workspaceDir });
|
||||
const bundled = resolveBundledPluginSources({
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
});
|
||||
return findBundledPluginSourceInMap({
|
||||
bundled,
|
||||
lookup: params.lookup,
|
||||
|
|
|
|||
|
|
@ -1,28 +1,15 @@
|
|||
import { Command } from "commander";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
memoryRegister: vi.fn(),
|
||||
otherRegister: vi.fn(),
|
||||
loadOpenClawPlugins: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./loader.js", () => ({
|
||||
loadOpenClawPlugins: () => ({
|
||||
cliRegistrars: [
|
||||
{
|
||||
pluginId: "memory-core",
|
||||
register: mocks.memoryRegister,
|
||||
commands: ["memory"],
|
||||
source: "bundled",
|
||||
},
|
||||
{
|
||||
pluginId: "other",
|
||||
register: mocks.otherRegister,
|
||||
commands: ["other"],
|
||||
source: "bundled",
|
||||
},
|
||||
],
|
||||
}),
|
||||
loadOpenClawPlugins: (...args: unknown[]) => mocks.loadOpenClawPlugins(...args),
|
||||
}));
|
||||
|
||||
import { registerPluginCliCommands } from "./cli.js";
|
||||
|
|
@ -31,6 +18,23 @@ describe("registerPluginCliCommands", () => {
|
|||
beforeEach(() => {
|
||||
mocks.memoryRegister.mockClear();
|
||||
mocks.otherRegister.mockClear();
|
||||
mocks.loadOpenClawPlugins.mockReset();
|
||||
mocks.loadOpenClawPlugins.mockReturnValue({
|
||||
cliRegistrars: [
|
||||
{
|
||||
pluginId: "memory-core",
|
||||
register: mocks.memoryRegister,
|
||||
commands: ["memory"],
|
||||
source: "bundled",
|
||||
},
|
||||
{
|
||||
pluginId: "other",
|
||||
register: mocks.otherRegister,
|
||||
commands: ["other"],
|
||||
source: "bundled",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("skips plugin CLI registrars when commands already exist", () => {
|
||||
|
|
@ -43,4 +47,17 @@ describe("registerPluginCliCommands", () => {
|
|||
expect(mocks.memoryRegister).not.toHaveBeenCalled();
|
||||
expect(mocks.otherRegister).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forwards an explicit env to plugin loading", () => {
|
||||
const program = new Command();
|
||||
const env = { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv;
|
||||
|
||||
registerPluginCliCommands(program, {} as OpenClawConfig, env);
|
||||
|
||||
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ import type { PluginLogger } from "./types.js";
|
|||
|
||||
const log = createSubsystemLogger("plugins");
|
||||
|
||||
export function registerPluginCliCommands(program: Command, cfg?: OpenClawConfig) {
|
||||
export function registerPluginCliCommands(
|
||||
program: Command,
|
||||
cfg?: OpenClawConfig,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
) {
|
||||
const config = cfg ?? loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const logger: PluginLogger = {
|
||||
|
|
@ -20,6 +24,7 @@ export function registerPluginCliCommands(program: Command, cfg?: OpenClawConfig
|
|||
const registry = loadOpenClawPlugins({
|
||||
config,
|
||||
workspaceDir,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -82,6 +82,27 @@ describe("discoverOpenClawPlugins", () => {
|
|||
expect(ids).toContain("beta");
|
||||
});
|
||||
|
||||
it("resolves tilde workspace dirs against the provided env", () => {
|
||||
const stateDir = makeTempDir();
|
||||
const homeDir = makeTempDir();
|
||||
const workspaceRoot = path.join(homeDir, "workspace");
|
||||
const workspaceExt = path.join(workspaceRoot, ".openclaw", "extensions");
|
||||
fs.mkdirSync(workspaceExt, { recursive: true });
|
||||
fs.writeFileSync(path.join(workspaceExt, "tilde-workspace.ts"), "export default {}", "utf-8");
|
||||
|
||||
const result = discoverOpenClawPlugins({
|
||||
workspaceDir: "~/workspace",
|
||||
env: {
|
||||
...buildDiscoveryEnv(stateDir),
|
||||
HOME: homeDir,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.candidates.some((candidate) => candidate.idHint === "tilde-workspace")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores backup and disabled plugin directories in scanned roots", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const globalExt = path.join(stateDir, "extensions");
|
||||
|
|
@ -393,4 +414,93 @@ describe("discoverOpenClawPlugins", () => {
|
|||
});
|
||||
expect(third.candidates.some((candidate) => candidate.idHint === "cached")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not reuse discovery results across env root changes", () => {
|
||||
const stateDirA = makeTempDir();
|
||||
const stateDirB = makeTempDir();
|
||||
const globalExtA = path.join(stateDirA, "extensions");
|
||||
const globalExtB = path.join(stateDirB, "extensions");
|
||||
fs.mkdirSync(globalExtA, { recursive: true });
|
||||
fs.mkdirSync(globalExtB, { recursive: true });
|
||||
fs.writeFileSync(path.join(globalExtA, "alpha.ts"), "export default function () {}", "utf-8");
|
||||
fs.writeFileSync(path.join(globalExtB, "beta.ts"), "export default function () {}", "utf-8");
|
||||
|
||||
const first = discoverOpenClawPlugins({
|
||||
env: {
|
||||
...buildDiscoveryEnv(stateDirA),
|
||||
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000",
|
||||
},
|
||||
});
|
||||
const second = discoverOpenClawPlugins({
|
||||
env: {
|
||||
...buildDiscoveryEnv(stateDirB),
|
||||
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000",
|
||||
},
|
||||
});
|
||||
|
||||
expect(first.candidates.some((candidate) => candidate.idHint === "alpha")).toBe(true);
|
||||
expect(first.candidates.some((candidate) => candidate.idHint === "beta")).toBe(false);
|
||||
expect(second.candidates.some((candidate) => candidate.idHint === "alpha")).toBe(false);
|
||||
expect(second.candidates.some((candidate) => candidate.idHint === "beta")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not reuse extra-path discovery across env home changes", () => {
|
||||
const stateDir = makeTempDir();
|
||||
const homeA = makeTempDir();
|
||||
const homeB = makeTempDir();
|
||||
const pluginA = path.join(homeA, "plugins", "demo.ts");
|
||||
const pluginB = path.join(homeB, "plugins", "demo.ts");
|
||||
fs.mkdirSync(path.dirname(pluginA), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(pluginB), { recursive: true });
|
||||
fs.writeFileSync(pluginA, "export default {}", "utf-8");
|
||||
fs.writeFileSync(pluginB, "export default {}", "utf-8");
|
||||
|
||||
const first = discoverOpenClawPlugins({
|
||||
extraPaths: ["~/plugins/demo.ts"],
|
||||
env: {
|
||||
...buildDiscoveryEnv(stateDir),
|
||||
HOME: homeA,
|
||||
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000",
|
||||
},
|
||||
});
|
||||
const second = discoverOpenClawPlugins({
|
||||
extraPaths: ["~/plugins/demo.ts"],
|
||||
env: {
|
||||
...buildDiscoveryEnv(stateDir),
|
||||
HOME: homeB,
|
||||
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000",
|
||||
},
|
||||
});
|
||||
|
||||
expect(first.candidates.find((candidate) => candidate.idHint === "demo")?.source).toBe(pluginA);
|
||||
expect(second.candidates.find((candidate) => candidate.idHint === "demo")?.source).toBe(
|
||||
pluginB,
|
||||
);
|
||||
});
|
||||
|
||||
it("treats configured load-path order as cache-significant", () => {
|
||||
const stateDir = makeTempDir();
|
||||
const pluginA = path.join(stateDir, "plugins", "alpha.ts");
|
||||
const pluginB = path.join(stateDir, "plugins", "beta.ts");
|
||||
fs.mkdirSync(path.dirname(pluginA), { recursive: true });
|
||||
fs.writeFileSync(pluginA, "export default {}", "utf-8");
|
||||
fs.writeFileSync(pluginB, "export default {}", "utf-8");
|
||||
|
||||
const env = {
|
||||
...buildDiscoveryEnv(stateDir),
|
||||
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000",
|
||||
};
|
||||
|
||||
const first = discoverOpenClawPlugins({
|
||||
extraPaths: [pluginA, pluginB],
|
||||
env,
|
||||
});
|
||||
const second = discoverOpenClawPlugins({
|
||||
extraPaths: [pluginB, pluginA],
|
||||
env,
|
||||
});
|
||||
|
||||
expect(first.candidates.map((candidate) => candidate.idHint)).toEqual(["alpha", "beta"]);
|
||||
expect(second.candidates.map((candidate) => candidate.idHint)).toEqual(["beta", "alpha"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
|
||||
import { resolveConfigDir, resolveUserPath } from "../utils.js";
|
||||
import { resolveBundledPluginsDir } from "./bundled-dir.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import {
|
||||
DEFAULT_PLUGIN_ENTRY_CANDIDATES,
|
||||
getPackageManifestMetadata,
|
||||
|
|
@ -11,6 +10,7 @@ import {
|
|||
type PackageManifest,
|
||||
} from "./manifest.js";
|
||||
import { formatPosixMode, isPathInside, safeRealpathSync, safeStatSync } from "./path-safety.js";
|
||||
import { resolvePluginCacheInputs, resolvePluginSourceRoots } from "./roots.js";
|
||||
import type { PluginDiagnostic, PluginOrigin } from "./types.js";
|
||||
|
||||
const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]);
|
||||
|
|
@ -71,17 +71,16 @@ function buildDiscoveryCacheKey(params: {
|
|||
ownershipUid?: number | null;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): string {
|
||||
const workspaceKey = params.workspaceDir ? resolveUserPath(params.workspaceDir) : "";
|
||||
const configExtensionsRoot = path.join(resolveConfigDir(params.env), "extensions");
|
||||
const bundledRoot = resolveBundledPluginsDir(params.env) ?? "";
|
||||
const normalizedExtraPaths = (params.extraPaths ?? [])
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => resolveUserPath(entry))
|
||||
.toSorted();
|
||||
const { roots, loadPaths } = resolvePluginCacheInputs({
|
||||
workspaceDir: params.workspaceDir,
|
||||
loadPaths: params.extraPaths,
|
||||
env: params.env,
|
||||
});
|
||||
const workspaceKey = roots.workspace ?? "";
|
||||
const configExtensionsRoot = roots.global ?? "";
|
||||
const bundledRoot = roots.stock ?? "";
|
||||
const ownershipUid = params.ownershipUid ?? currentUid();
|
||||
return `${workspaceKey}::${ownershipUid ?? "none"}::${configExtensionsRoot}::${bundledRoot}::${JSON.stringify(normalizedExtraPaths)}`;
|
||||
return `${workspaceKey}::${ownershipUid ?? "none"}::${configExtensionsRoot}::${bundledRoot}::${JSON.stringify(loadPaths)}`;
|
||||
}
|
||||
|
||||
function currentUid(overrideUid?: number | null): number | null {
|
||||
|
|
@ -526,11 +525,12 @@ function discoverFromPath(params: {
|
|||
origin: PluginOrigin;
|
||||
ownershipUid?: number | null;
|
||||
workspaceDir?: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
candidates: PluginCandidate[];
|
||||
diagnostics: PluginDiagnostic[];
|
||||
seen: Set<string>;
|
||||
}) {
|
||||
const resolved = resolveUserPath(params.rawPath);
|
||||
const resolved = resolveUserPath(params.rawPath, params.env);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
params.diagnostics.push({
|
||||
level: "error",
|
||||
|
|
@ -663,6 +663,8 @@ export function discoverOpenClawPlugins(params: {
|
|||
const diagnostics: PluginDiagnostic[] = [];
|
||||
const seen = new Set<string>();
|
||||
const workspaceDir = params.workspaceDir?.trim();
|
||||
const workspaceRoot = workspaceDir ? resolveUserPath(workspaceDir, env) : undefined;
|
||||
const roots = resolvePluginSourceRoots({ workspaceDir: workspaceRoot, env });
|
||||
|
||||
const extra = params.extraPaths ?? [];
|
||||
for (const extraPath of extra) {
|
||||
|
|
@ -678,31 +680,27 @@ export function discoverOpenClawPlugins(params: {
|
|||
origin: "config",
|
||||
ownershipUid: params.ownershipUid,
|
||||
workspaceDir: workspaceDir?.trim() || undefined,
|
||||
env,
|
||||
candidates,
|
||||
diagnostics,
|
||||
seen,
|
||||
});
|
||||
}
|
||||
if (workspaceDir) {
|
||||
const workspaceRoot = resolveUserPath(workspaceDir);
|
||||
const workspaceExtDirs = [path.join(workspaceRoot, ".openclaw", "extensions")];
|
||||
for (const dir of workspaceExtDirs) {
|
||||
discoverInDirectory({
|
||||
dir,
|
||||
origin: "workspace",
|
||||
ownershipUid: params.ownershipUid,
|
||||
workspaceDir: workspaceRoot,
|
||||
candidates,
|
||||
diagnostics,
|
||||
seen,
|
||||
});
|
||||
}
|
||||
if (roots.workspace && workspaceRoot) {
|
||||
discoverInDirectory({
|
||||
dir: roots.workspace,
|
||||
origin: "workspace",
|
||||
ownershipUid: params.ownershipUid,
|
||||
workspaceDir: workspaceRoot,
|
||||
candidates,
|
||||
diagnostics,
|
||||
seen,
|
||||
});
|
||||
}
|
||||
|
||||
const bundledDir = resolveBundledPluginsDir(env);
|
||||
if (bundledDir) {
|
||||
if (roots.stock) {
|
||||
discoverInDirectory({
|
||||
dir: bundledDir,
|
||||
dir: roots.stock,
|
||||
origin: "bundled",
|
||||
ownershipUid: params.ownershipUid,
|
||||
candidates,
|
||||
|
|
@ -713,9 +711,8 @@ export function discoverOpenClawPlugins(params: {
|
|||
|
||||
// Keep auto-discovered global extensions behind bundled plugins.
|
||||
// Users can still intentionally override via plugins.load.paths (origin=config).
|
||||
const globalDir = path.join(resolveConfigDir(env), "extensions");
|
||||
discoverInDirectory({
|
||||
dir: globalDir,
|
||||
dir: roots.global,
|
||||
origin: "global",
|
||||
ownershipUid: params.ownershipUid,
|
||||
candidates,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ async function importFreshPluginTestModules() {
|
|||
|
||||
const {
|
||||
__testing,
|
||||
clearPluginLoaderCache,
|
||||
createHookRunner,
|
||||
getGlobalHookRunner,
|
||||
loadOpenClawPlugins,
|
||||
|
|
@ -80,6 +81,7 @@ function writePlugin(params: {
|
|||
}): TempPlugin {
|
||||
const dir = params.dir ?? makeTempDir();
|
||||
const filename = params.filename ?? `${params.id}.cjs`;
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, filename);
|
||||
fs.writeFileSync(file, params.body, "utf-8");
|
||||
fs.writeFileSync(
|
||||
|
|
@ -265,6 +267,7 @@ function createPluginSdkAliasFixture(params?: {
|
|||
}
|
||||
|
||||
afterEach(() => {
|
||||
clearPluginLoaderCache();
|
||||
if (prevBundledDir === undefined) {
|
||||
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
|
||||
} else {
|
||||
|
|
@ -449,6 +452,290 @@ describe("loadOpenClawPlugins", () => {
|
|||
resetGlobalHookRunner();
|
||||
});
|
||||
|
||||
it("does not reuse cached bundled plugin registries across env changes", () => {
|
||||
const bundledA = makeTempDir();
|
||||
const bundledB = makeTempDir();
|
||||
const pluginA = writePlugin({
|
||||
id: "cache-root",
|
||||
dir: path.join(bundledA, "cache-root"),
|
||||
filename: "index.cjs",
|
||||
body: `module.exports = { id: "cache-root", register() {} };`,
|
||||
});
|
||||
const pluginB = writePlugin({
|
||||
id: "cache-root",
|
||||
dir: path.join(bundledB, "cache-root"),
|
||||
filename: "index.cjs",
|
||||
body: `module.exports = { id: "cache-root", register() {} };`,
|
||||
});
|
||||
|
||||
const options = {
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["cache-root"],
|
||||
entries: {
|
||||
"cache-root": { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const first = loadOpenClawPlugins({
|
||||
...options,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledA,
|
||||
},
|
||||
});
|
||||
const second = loadOpenClawPlugins({
|
||||
...options,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledB,
|
||||
},
|
||||
});
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(
|
||||
fs.realpathSync(first.plugins.find((entry) => entry.id === "cache-root")?.source ?? ""),
|
||||
).toBe(fs.realpathSync(pluginA.file));
|
||||
expect(
|
||||
fs.realpathSync(second.plugins.find((entry) => entry.id === "cache-root")?.source ?? ""),
|
||||
).toBe(fs.realpathSync(pluginB.file));
|
||||
});
|
||||
|
||||
it("does not reuse cached load-path plugin registries across env home changes", () => {
|
||||
const homeA = makeTempDir();
|
||||
const homeB = makeTempDir();
|
||||
const stateDir = makeTempDir();
|
||||
const bundledDir = makeTempDir();
|
||||
const pluginA = writePlugin({
|
||||
id: "demo",
|
||||
dir: path.join(homeA, "plugins", "demo"),
|
||||
filename: "index.cjs",
|
||||
body: `module.exports = { id: "demo", register() {} };`,
|
||||
});
|
||||
const pluginB = writePlugin({
|
||||
id: "demo",
|
||||
dir: path.join(homeB, "plugins", "demo"),
|
||||
filename: "index.cjs",
|
||||
body: `module.exports = { id: "demo", register() {} };`,
|
||||
});
|
||||
|
||||
const options = {
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["demo"],
|
||||
entries: {
|
||||
demo: { enabled: true },
|
||||
},
|
||||
load: {
|
||||
paths: ["~/plugins/demo"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const first = loadOpenClawPlugins({
|
||||
...options,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: homeA,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir,
|
||||
},
|
||||
});
|
||||
const second = loadOpenClawPlugins({
|
||||
...options,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: homeB,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir,
|
||||
},
|
||||
});
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(fs.realpathSync(first.plugins.find((entry) => entry.id === "demo")?.source ?? "")).toBe(
|
||||
fs.realpathSync(pluginA.file),
|
||||
);
|
||||
expect(fs.realpathSync(second.plugins.find((entry) => entry.id === "demo")?.source ?? "")).toBe(
|
||||
fs.realpathSync(pluginB.file),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not reuse cached registries when env-resolved install paths change", () => {
|
||||
useNoBundledPlugins();
|
||||
const openclawHome = makeTempDir();
|
||||
const ignoredHome = makeTempDir();
|
||||
const stateDir = makeTempDir();
|
||||
const pluginDir = path.join(openclawHome, "plugins", "tracked-install-cache");
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
const plugin = writePlugin({
|
||||
id: "tracked-install-cache",
|
||||
dir: pluginDir,
|
||||
filename: "index.cjs",
|
||||
body: `module.exports = { id: "tracked-install-cache", register() {} };`,
|
||||
});
|
||||
|
||||
const options = {
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [plugin.file] },
|
||||
allow: ["tracked-install-cache"],
|
||||
installs: {
|
||||
"tracked-install-cache": {
|
||||
source: "path" as const,
|
||||
installPath: "~/plugins/tracked-install-cache",
|
||||
sourcePath: "~/plugins/tracked-install-cache",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const first = loadOpenClawPlugins({
|
||||
...options,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_HOME: openclawHome,
|
||||
HOME: ignoredHome,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
CLAWDBOT_STATE_DIR: undefined,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
|
||||
},
|
||||
});
|
||||
const secondHome = makeTempDir();
|
||||
const secondOptions = {
|
||||
...options,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_HOME: secondHome,
|
||||
HOME: ignoredHome,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
CLAWDBOT_STATE_DIR: undefined,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
|
||||
},
|
||||
};
|
||||
const second = loadOpenClawPlugins(secondOptions);
|
||||
const third = loadOpenClawPlugins(secondOptions);
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(third).toBe(second);
|
||||
});
|
||||
|
||||
it("evicts least recently used registries when the loader cache exceeds its cap", () => {
|
||||
useNoBundledPlugins();
|
||||
const plugin = writePlugin({
|
||||
id: "cache-eviction",
|
||||
filename: "cache-eviction.cjs",
|
||||
body: `module.exports = { id: "cache-eviction", register() {} };`,
|
||||
});
|
||||
const stateDirs = Array.from({ length: __testing.maxPluginRegistryCacheEntries + 1 }, () =>
|
||||
makeTempDir(),
|
||||
);
|
||||
|
||||
const loadWithStateDir = (stateDir: string) =>
|
||||
loadOpenClawPlugins({
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
|
||||
},
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["cache-eviction"],
|
||||
load: {
|
||||
paths: [plugin.file],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const first = loadWithStateDir(stateDirs[0] ?? makeTempDir());
|
||||
const second = loadWithStateDir(stateDirs[1] ?? makeTempDir());
|
||||
|
||||
expect(loadWithStateDir(stateDirs[0] ?? makeTempDir())).toBe(first);
|
||||
|
||||
for (const stateDir of stateDirs.slice(2)) {
|
||||
loadWithStateDir(stateDir);
|
||||
}
|
||||
|
||||
expect(loadWithStateDir(stateDirs[0] ?? makeTempDir())).toBe(first);
|
||||
expect(loadWithStateDir(stateDirs[1] ?? makeTempDir())).not.toBe(second);
|
||||
});
|
||||
|
||||
it("normalizes bundled plugin env overrides against the provided env", () => {
|
||||
const bundledDir = makeTempDir();
|
||||
const homeDir = path.dirname(bundledDir);
|
||||
const override = `~/${path.basename(bundledDir)}`;
|
||||
const plugin = writePlugin({
|
||||
id: "tilde-bundled",
|
||||
dir: path.join(bundledDir, "tilde-bundled"),
|
||||
filename: "index.cjs",
|
||||
body: `module.exports = { id: "tilde-bundled", register() {} };`,
|
||||
});
|
||||
|
||||
const registry = loadOpenClawPlugins({
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: override,
|
||||
},
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["tilde-bundled"],
|
||||
entries: {
|
||||
"tilde-bundled": { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
fs.realpathSync(registry.plugins.find((entry) => entry.id === "tilde-bundled")?.source ?? ""),
|
||||
).toBe(fs.realpathSync(plugin.file));
|
||||
});
|
||||
|
||||
it("prefers OPENCLAW_HOME over HOME for env-expanded load paths", () => {
|
||||
const ignoredHome = makeTempDir();
|
||||
const openclawHome = makeTempDir();
|
||||
const stateDir = makeTempDir();
|
||||
const bundledDir = makeTempDir();
|
||||
const plugin = writePlugin({
|
||||
id: "openclaw-home-demo",
|
||||
dir: path.join(openclawHome, "plugins", "openclaw-home-demo"),
|
||||
filename: "index.cjs",
|
||||
body: `module.exports = { id: "openclaw-home-demo", register() {} };`,
|
||||
});
|
||||
|
||||
const registry = loadOpenClawPlugins({
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: ignoredHome,
|
||||
OPENCLAW_HOME: openclawHome,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir,
|
||||
},
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["openclaw-home-demo"],
|
||||
entries: {
|
||||
"openclaw-home-demo": { enabled: true },
|
||||
},
|
||||
load: {
|
||||
paths: ["~/plugins/openclaw-home-demo"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
fs.realpathSync(
|
||||
registry.plugins.find((entry) => entry.id === "openclaw-home-demo")?.source ?? "",
|
||||
),
|
||||
).toBe(fs.realpathSync(plugin.file));
|
||||
});
|
||||
|
||||
it("loads plugins when source and root differ only by realpath alias", () => {
|
||||
useNoBundledPlugins();
|
||||
const plugin = writePlugin({
|
||||
|
|
@ -1197,6 +1484,97 @@ describe("loadOpenClawPlugins", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("does not warn about missing provenance for env-resolved load paths", () => {
|
||||
useNoBundledPlugins();
|
||||
const openclawHome = makeTempDir();
|
||||
const ignoredHome = makeTempDir();
|
||||
const stateDir = makeTempDir();
|
||||
const pluginDir = path.join(openclawHome, "plugins", "tracked-load-path");
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
const plugin = writePlugin({
|
||||
id: "tracked-load-path",
|
||||
dir: pluginDir,
|
||||
filename: "index.cjs",
|
||||
body: `module.exports = { id: "tracked-load-path", register() {} };`,
|
||||
});
|
||||
|
||||
const warnings: string[] = [];
|
||||
const registry = loadOpenClawPlugins({
|
||||
cache: false,
|
||||
logger: createWarningLogger(warnings),
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_HOME: openclawHome,
|
||||
HOME: ignoredHome,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
CLAWDBOT_STATE_DIR: undefined,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
|
||||
},
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: ["~/plugins/tracked-load-path"] },
|
||||
allow: ["tracked-load-path"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.plugins.find((entry) => entry.id === "tracked-load-path")?.source).toBe(
|
||||
plugin.file,
|
||||
);
|
||||
expect(
|
||||
warnings.some((msg) => msg.includes("loaded without install/load-path provenance")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not warn about missing provenance for env-resolved install paths", () => {
|
||||
useNoBundledPlugins();
|
||||
const openclawHome = makeTempDir();
|
||||
const ignoredHome = makeTempDir();
|
||||
const stateDir = makeTempDir();
|
||||
const pluginDir = path.join(openclawHome, "plugins", "tracked-install-path");
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
const plugin = writePlugin({
|
||||
id: "tracked-install-path",
|
||||
dir: pluginDir,
|
||||
filename: "index.cjs",
|
||||
body: `module.exports = { id: "tracked-install-path", register() {} };`,
|
||||
});
|
||||
|
||||
const warnings: string[] = [];
|
||||
const registry = loadOpenClawPlugins({
|
||||
cache: false,
|
||||
logger: createWarningLogger(warnings),
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_HOME: openclawHome,
|
||||
HOME: ignoredHome,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
CLAWDBOT_STATE_DIR: undefined,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
|
||||
},
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [plugin.file] },
|
||||
allow: ["tracked-install-path"],
|
||||
installs: {
|
||||
"tracked-install-path": {
|
||||
source: "path",
|
||||
installPath: "~/plugins/tracked-install-path",
|
||||
sourcePath: "~/plugins/tracked-install-path",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.plugins.find((entry) => entry.id === "tracked-install-path")?.source).toBe(
|
||||
plugin.file,
|
||||
);
|
||||
expect(
|
||||
warnings.some((msg) => msg.includes("loaded without install/load-path provenance")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects plugin entry files that escape plugin root via symlink", () => {
|
||||
useNoBundledPlugins();
|
||||
const { outsideEntry, linkedEntry } = createEscapingEntryFixture({
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import path from "node:path";
|
|||
import { fileURLToPath } from "node:url";
|
||||
import { createJiti } from "jiti";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
|
||||
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
|
||||
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
|
||||
|
|
@ -21,6 +22,7 @@ import { initializeGlobalHookRunner } from "./hook-runner-global.js";
|
|||
import { loadPluginManifestRegistry } from "./manifest-registry.js";
|
||||
import { isPathInside, safeStatSync } from "./path-safety.js";
|
||||
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
|
||||
import { resolvePluginCacheInputs } from "./roots.js";
|
||||
import { setActivePluginRegistry } from "./runtime.js";
|
||||
import { createPluginRuntime, type CreatePluginRuntimeOptions } from "./runtime/index.js";
|
||||
import type { PluginRuntime } from "./runtime/types.js";
|
||||
|
|
@ -37,6 +39,9 @@ export type PluginLoadResult = PluginRegistry;
|
|||
export type PluginLoadOptions = {
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
// Allows callers to resolve plugin roots and load paths against an explicit env
|
||||
// instead of the process-global environment.
|
||||
env?: NodeJS.ProcessEnv;
|
||||
logger?: PluginLogger;
|
||||
coreGatewayHandlers?: Record<string, GatewayRequestHandler>;
|
||||
runtimeOptions?: CreatePluginRuntimeOptions;
|
||||
|
|
@ -44,8 +49,13 @@ export type PluginLoadOptions = {
|
|||
mode?: "full" | "validate";
|
||||
};
|
||||
|
||||
const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 32;
|
||||
const registryCache = new Map<string, PluginRegistry>();
|
||||
|
||||
export function clearPluginLoaderCache(): void {
|
||||
registryCache.clear();
|
||||
}
|
||||
|
||||
const defaultLogger = () => createSubsystemLogger("plugins");
|
||||
|
||||
type PluginSdkAliasCandidateKind = "dist" | "src";
|
||||
|
|
@ -162,14 +172,66 @@ export const __testing = {
|
|||
listPluginSdkExportedSubpaths,
|
||||
resolvePluginSdkAliasCandidateOrder,
|
||||
resolvePluginSdkAliasFile,
|
||||
maxPluginRegistryCacheEntries: MAX_PLUGIN_REGISTRY_CACHE_ENTRIES,
|
||||
};
|
||||
|
||||
function getCachedPluginRegistry(cacheKey: string): PluginRegistry | undefined {
|
||||
const cached = registryCache.get(cacheKey);
|
||||
if (!cached) {
|
||||
return undefined;
|
||||
}
|
||||
// Refresh insertion order so frequently reused registries survive eviction.
|
||||
registryCache.delete(cacheKey);
|
||||
registryCache.set(cacheKey, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
function setCachedPluginRegistry(cacheKey: string, registry: PluginRegistry): void {
|
||||
if (registryCache.has(cacheKey)) {
|
||||
registryCache.delete(cacheKey);
|
||||
}
|
||||
registryCache.set(cacheKey, registry);
|
||||
while (registryCache.size > MAX_PLUGIN_REGISTRY_CACHE_ENTRIES) {
|
||||
const oldestKey = registryCache.keys().next().value;
|
||||
if (!oldestKey) {
|
||||
break;
|
||||
}
|
||||
registryCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
function buildCacheKey(params: {
|
||||
workspaceDir?: string;
|
||||
plugins: NormalizedPluginsConfig;
|
||||
installs?: Record<string, PluginInstallRecord>;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): string {
|
||||
const workspaceKey = params.workspaceDir ? resolveUserPath(params.workspaceDir) : "";
|
||||
return `${workspaceKey}::${JSON.stringify(params.plugins)}`;
|
||||
const { roots, loadPaths } = resolvePluginCacheInputs({
|
||||
workspaceDir: params.workspaceDir,
|
||||
loadPaths: params.plugins.loadPaths,
|
||||
env: params.env,
|
||||
});
|
||||
const installs = Object.fromEntries(
|
||||
Object.entries(params.installs ?? {}).map(([pluginId, install]) => [
|
||||
pluginId,
|
||||
{
|
||||
...install,
|
||||
installPath:
|
||||
typeof install.installPath === "string"
|
||||
? resolveUserPath(install.installPath, params.env)
|
||||
: install.installPath,
|
||||
sourcePath:
|
||||
typeof install.sourcePath === "string"
|
||||
? resolveUserPath(install.sourcePath, params.env)
|
||||
: install.sourcePath,
|
||||
},
|
||||
]),
|
||||
);
|
||||
return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({
|
||||
...params.plugins,
|
||||
installs,
|
||||
loadPaths,
|
||||
})}`;
|
||||
}
|
||||
|
||||
function validatePluginConfig(params: {
|
||||
|
|
@ -306,12 +368,16 @@ function createPathMatcher(): PathMatcher {
|
|||
return { exact: new Set<string>(), dirs: [] };
|
||||
}
|
||||
|
||||
function addPathToMatcher(matcher: PathMatcher, rawPath: string): void {
|
||||
function addPathToMatcher(
|
||||
matcher: PathMatcher,
|
||||
rawPath: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): void {
|
||||
const trimmed = rawPath.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
const resolved = resolveUserPath(trimmed);
|
||||
const resolved = resolveUserPath(trimmed, env);
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -336,10 +402,11 @@ function matchesPathMatcher(matcher: PathMatcher, sourcePath: string): boolean {
|
|||
function buildProvenanceIndex(params: {
|
||||
config: OpenClawConfig;
|
||||
normalizedLoadPaths: string[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): PluginProvenanceIndex {
|
||||
const loadPathMatcher = createPathMatcher();
|
||||
for (const loadPath of params.normalizedLoadPaths) {
|
||||
addPathToMatcher(loadPathMatcher, loadPath);
|
||||
addPathToMatcher(loadPathMatcher, loadPath, params.env);
|
||||
}
|
||||
|
||||
const installRules = new Map<string, InstallTrackingRule>();
|
||||
|
|
@ -356,7 +423,7 @@ function buildProvenanceIndex(params: {
|
|||
rule.trackedWithoutPaths = true;
|
||||
} else {
|
||||
for (const trackedPath of trackedPaths) {
|
||||
addPathToMatcher(rule.matcher, trackedPath);
|
||||
addPathToMatcher(rule.matcher, trackedPath, params.env);
|
||||
}
|
||||
}
|
||||
installRules.set(pluginId, rule);
|
||||
|
|
@ -369,8 +436,9 @@ function isTrackedByProvenance(params: {
|
|||
pluginId: string;
|
||||
source: string;
|
||||
index: PluginProvenanceIndex;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): boolean {
|
||||
const sourcePath = resolveUserPath(params.source);
|
||||
const sourcePath = resolveUserPath(params.source, params.env);
|
||||
const installRule = params.index.installRules.get(params.pluginId);
|
||||
if (installRule) {
|
||||
if (installRule.trackedWithoutPaths) {
|
||||
|
|
@ -413,6 +481,7 @@ function warnAboutUntrackedLoadedPlugins(params: {
|
|||
registry: PluginRegistry;
|
||||
provenance: PluginProvenanceIndex;
|
||||
logger: PluginLogger;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}) {
|
||||
for (const plugin of params.registry.plugins) {
|
||||
if (plugin.status !== "loaded" || plugin.origin === "bundled") {
|
||||
|
|
@ -423,6 +492,7 @@ function warnAboutUntrackedLoadedPlugins(params: {
|
|||
pluginId: plugin.id,
|
||||
source: plugin.source,
|
||||
index: params.provenance,
|
||||
env: params.env,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
|
|
@ -445,19 +515,22 @@ function activatePluginRegistry(registry: PluginRegistry, cacheKey: string): voi
|
|||
}
|
||||
|
||||
export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegistry {
|
||||
const env = options.env ?? process.env;
|
||||
// Test env: default-disable plugins unless explicitly configured.
|
||||
// This keeps unit/gateway suites fast and avoids loading heavyweight plugin deps by accident.
|
||||
const cfg = applyTestPluginDefaults(options.config ?? {}, process.env);
|
||||
const cfg = applyTestPluginDefaults(options.config ?? {}, env);
|
||||
const logger = options.logger ?? defaultLogger();
|
||||
const validateOnly = options.mode === "validate";
|
||||
const normalized = normalizePluginsConfig(cfg.plugins);
|
||||
const cacheKey = buildCacheKey({
|
||||
workspaceDir: options.workspaceDir,
|
||||
plugins: normalized,
|
||||
installs: cfg.plugins?.installs,
|
||||
env,
|
||||
});
|
||||
const cacheEnabled = options.cache !== false;
|
||||
if (cacheEnabled) {
|
||||
const cached = registryCache.get(cacheKey);
|
||||
const cached = getCachedPluginRegistry(cacheKey);
|
||||
if (cached) {
|
||||
activatePluginRegistry(cached, cacheKey);
|
||||
return cached;
|
||||
|
|
@ -510,11 +583,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||
workspaceDir: options.workspaceDir,
|
||||
extraPaths: normalized.loadPaths,
|
||||
cache: options.cache,
|
||||
env,
|
||||
});
|
||||
const manifestRegistry = loadPluginManifestRegistry({
|
||||
config: cfg,
|
||||
workspaceDir: options.workspaceDir,
|
||||
cache: options.cache,
|
||||
env,
|
||||
candidates: discovery.candidates,
|
||||
diagnostics: discovery.diagnostics,
|
||||
});
|
||||
|
|
@ -532,6 +607,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||
const provenance = buildProvenanceIndex({
|
||||
config: cfg,
|
||||
normalizedLoadPaths: normalized.loadPaths,
|
||||
env,
|
||||
});
|
||||
|
||||
// Lazy: avoid creating the Jiti loader when all plugins are disabled (common in unit tests).
|
||||
|
|
@ -810,10 +886,11 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||
registry,
|
||||
provenance,
|
||||
logger,
|
||||
env,
|
||||
});
|
||||
|
||||
if (cacheEnabled) {
|
||||
registryCache.set(cacheKey, registry);
|
||||
setCachedPluginRegistry(cacheKey, registry);
|
||||
}
|
||||
activatePluginRegistry(registry, cacheKey);
|
||||
return registry;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { PluginCandidate } from "./discovery.js";
|
||||
import { loadPluginManifestRegistry } from "./manifest-registry.js";
|
||||
import {
|
||||
clearPluginManifestRegistryCache,
|
||||
loadPluginManifestRegistry,
|
||||
} from "./manifest-registry.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
|
|
@ -116,6 +119,7 @@ function expectUnsafeWorkspaceManifestRejected(params: {
|
|||
}
|
||||
|
||||
afterEach(() => {
|
||||
clearPluginManifestRegistryCache();
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (!dir) {
|
||||
|
|
@ -264,4 +268,102 @@ describe("loadPluginManifestRegistry", () => {
|
|||
expect(registry.plugins.some((entry) => entry.id === "bundled-hardlink")).toBe(true);
|
||||
expect(hasUnsafeManifestDiagnostic(registry)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not reuse cached bundled plugin roots across env changes", () => {
|
||||
const bundledA = makeTempDir();
|
||||
const bundledB = makeTempDir();
|
||||
const matrixA = path.join(bundledA, "matrix");
|
||||
const matrixB = path.join(bundledB, "matrix");
|
||||
fs.mkdirSync(matrixA, { recursive: true });
|
||||
fs.mkdirSync(matrixB, { recursive: true });
|
||||
writeManifest(matrixA, {
|
||||
id: "matrix",
|
||||
name: "Matrix A",
|
||||
configSchema: { type: "object" },
|
||||
});
|
||||
writeManifest(matrixB, {
|
||||
id: "matrix",
|
||||
name: "Matrix B",
|
||||
configSchema: { type: "object" },
|
||||
});
|
||||
fs.writeFileSync(path.join(matrixA, "index.ts"), "export default {}", "utf-8");
|
||||
fs.writeFileSync(path.join(matrixB, "index.ts"), "export default {}", "utf-8");
|
||||
|
||||
const first = loadPluginManifestRegistry({
|
||||
cache: true,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledA,
|
||||
},
|
||||
});
|
||||
const second = loadPluginManifestRegistry({
|
||||
cache: true,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledB,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
fs.realpathSync(first.plugins.find((plugin) => plugin.id === "matrix")?.rootDir ?? ""),
|
||||
).toBe(fs.realpathSync(matrixA));
|
||||
expect(
|
||||
fs.realpathSync(second.plugins.find((plugin) => plugin.id === "matrix")?.rootDir ?? ""),
|
||||
).toBe(fs.realpathSync(matrixB));
|
||||
});
|
||||
|
||||
it("does not reuse cached load-path manifests across env home changes", () => {
|
||||
const homeA = makeTempDir();
|
||||
const homeB = makeTempDir();
|
||||
const demoA = path.join(homeA, "plugins", "demo");
|
||||
const demoB = path.join(homeB, "plugins", "demo");
|
||||
fs.mkdirSync(demoA, { recursive: true });
|
||||
fs.mkdirSync(demoB, { recursive: true });
|
||||
writeManifest(demoA, {
|
||||
id: "demo",
|
||||
name: "Demo A",
|
||||
configSchema: { type: "object" },
|
||||
});
|
||||
writeManifest(demoB, {
|
||||
id: "demo",
|
||||
name: "Demo B",
|
||||
configSchema: { type: "object" },
|
||||
});
|
||||
fs.writeFileSync(path.join(demoA, "index.ts"), "export default {}", "utf-8");
|
||||
fs.writeFileSync(path.join(demoB, "index.ts"), "export default {}", "utf-8");
|
||||
|
||||
const config = {
|
||||
plugins: {
|
||||
load: {
|
||||
paths: ["~/plugins/demo"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const first = loadPluginManifestRegistry({
|
||||
cache: true,
|
||||
config,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: homeA,
|
||||
OPENCLAW_STATE_DIR: path.join(homeA, ".state"),
|
||||
},
|
||||
});
|
||||
const second = loadPluginManifestRegistry({
|
||||
cache: true,
|
||||
config,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: homeB,
|
||||
OPENCLAW_STATE_DIR: path.join(homeB, ".state"),
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
fs.realpathSync(first.plugins.find((plugin) => plugin.id === "demo")?.rootDir ?? ""),
|
||||
).toBe(fs.realpathSync(demoA));
|
||||
expect(
|
||||
fs.realpathSync(second.plugins.find((plugin) => plugin.id === "demo")?.rootDir ?? ""),
|
||||
).toBe(fs.realpathSync(demoB));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveConfigDir, resolveUserPath } from "../utils.js";
|
||||
import { resolveBundledPluginsDir } from "./bundled-dir.js";
|
||||
import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js";
|
||||
import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js";
|
||||
import { loadPluginManifest, type PluginManifest } from "./manifest.js";
|
||||
import { safeRealpathSync } from "./path-safety.js";
|
||||
import { resolvePluginCacheInputs } from "./roots.js";
|
||||
import type { PluginConfigUiHint, PluginDiagnostic, PluginKind, PluginOrigin } from "./types.js";
|
||||
|
||||
type SeenIdEntry = {
|
||||
|
|
@ -83,16 +81,16 @@ function buildCacheKey(params: {
|
|||
plugins: NormalizedPluginsConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): string {
|
||||
const workspaceKey = params.workspaceDir ? resolveUserPath(params.workspaceDir) : "";
|
||||
const configExtensionsRoot = path.join(resolveConfigDir(params.env), "extensions");
|
||||
const bundledRoot = resolveBundledPluginsDir(params.env) ?? "";
|
||||
const { roots, loadPaths } = resolvePluginCacheInputs({
|
||||
workspaceDir: params.workspaceDir,
|
||||
loadPaths: params.plugins.loadPaths,
|
||||
env: params.env,
|
||||
});
|
||||
const workspaceKey = roots.workspace ?? "";
|
||||
const configExtensionsRoot = roots.global;
|
||||
const bundledRoot = roots.stock ?? "";
|
||||
// The manifest registry only depends on where plugins are discovered from (workspace + load paths).
|
||||
// It does not depend on allow/deny/entries enable-state, so exclude those for higher cache hit rates.
|
||||
const loadPaths = params.plugins.loadPaths
|
||||
.map((p) => resolveUserPath(p))
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
.toSorted();
|
||||
return `${workspaceKey}::${configExtensionsRoot}::${bundledRoot}::${JSON.stringify(loadPaths)}`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolvePluginProviders } from "./providers.js";
|
||||
|
||||
const loadOpenClawPluginsMock = vi.fn();
|
||||
|
||||
vi.mock("./loader.js", () => ({
|
||||
loadOpenClawPlugins: (...args: unknown[]) => loadOpenClawPluginsMock(...args),
|
||||
}));
|
||||
|
||||
describe("resolvePluginProviders", () => {
|
||||
beforeEach(() => {
|
||||
loadOpenClawPluginsMock.mockReset();
|
||||
loadOpenClawPluginsMock.mockReturnValue({
|
||||
providers: [{ provider: { id: "demo-provider" } }],
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards an explicit env to plugin loading", () => {
|
||||
const env = { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv;
|
||||
|
||||
const providers = resolvePluginProviders({
|
||||
workspaceDir: "/workspace/explicit",
|
||||
env,
|
||||
});
|
||||
|
||||
expect(providers).toEqual([{ id: "demo-provider" }]);
|
||||
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceDir: "/workspace/explicit",
|
||||
env,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -8,10 +8,13 @@ const log = createSubsystemLogger("plugins");
|
|||
export function resolvePluginProviders(params: {
|
||||
config?: PluginLoadOptions["config"];
|
||||
workspaceDir?: string;
|
||||
/** Use an explicit env when plugin roots should resolve independently from process.env. */
|
||||
env?: PluginLoadOptions["env"];
|
||||
}): ProviderPlugin[] {
|
||||
const registry = loadOpenClawPlugins({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
logger: createPluginLoaderLogger(log),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import path from "node:path";
|
||||
import { resolveConfigDir, resolveUserPath } from "../utils.js";
|
||||
import { resolveBundledPluginsDir } from "./bundled-dir.js";
|
||||
|
||||
export type PluginSourceRoots = {
|
||||
stock?: string;
|
||||
global: string;
|
||||
workspace?: string;
|
||||
};
|
||||
|
||||
export type PluginCacheInputs = {
|
||||
roots: PluginSourceRoots;
|
||||
loadPaths: string[];
|
||||
};
|
||||
|
||||
export function resolvePluginSourceRoots(params: {
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): PluginSourceRoots {
|
||||
const env = params.env ?? process.env;
|
||||
const workspaceRoot = params.workspaceDir ? resolveUserPath(params.workspaceDir, env) : undefined;
|
||||
const stock = resolveBundledPluginsDir(env);
|
||||
const global = path.join(resolveConfigDir(env), "extensions");
|
||||
const workspace = workspaceRoot ? path.join(workspaceRoot, ".openclaw", "extensions") : undefined;
|
||||
return { stock, global, workspace };
|
||||
}
|
||||
|
||||
// Shared env-aware cache inputs for discovery, manifest, and loader caches.
|
||||
export function resolvePluginCacheInputs(params: {
|
||||
workspaceDir?: string;
|
||||
loadPaths?: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): PluginCacheInputs {
|
||||
const env = params.env ?? process.env;
|
||||
const roots = resolvePluginSourceRoots({
|
||||
workspaceDir: params.workspaceDir,
|
||||
env,
|
||||
});
|
||||
// Preserve caller order because load-path precedence follows input order.
|
||||
const loadPaths = (params.loadPaths ?? [])
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => resolveUserPath(entry, env));
|
||||
return { roots, loadPaths };
|
||||
}
|
||||
|
|
@ -1,17 +1,30 @@
|
|||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatPluginSourceForTable } from "./source-display.js";
|
||||
import { withEnv } from "../test-utils/env.js";
|
||||
import { formatPluginSourceForTable, resolvePluginSourceRoots } from "./source-display.js";
|
||||
|
||||
describe("formatPluginSourceForTable", () => {
|
||||
it("shortens bundled plugin sources under the stock root", () => {
|
||||
const stockRoot = path.resolve(
|
||||
path.sep,
|
||||
"opt",
|
||||
"homebrew",
|
||||
"lib",
|
||||
"node_modules",
|
||||
"openclaw",
|
||||
"extensions",
|
||||
);
|
||||
const globalRoot = path.resolve(path.sep, "Users", "x", ".openclaw", "extensions");
|
||||
const workspaceRoot = path.resolve(path.sep, "Users", "x", "ws", ".openclaw", "extensions");
|
||||
const out = formatPluginSourceForTable(
|
||||
{
|
||||
origin: "bundled",
|
||||
source: "/opt/homebrew/lib/node_modules/openclaw/extensions/bluebubbles/index.ts",
|
||||
source: path.join(stockRoot, "bluebubbles", "index.ts"),
|
||||
},
|
||||
{
|
||||
stock: "/opt/homebrew/lib/node_modules/openclaw/extensions",
|
||||
global: "/Users/x/.openclaw/extensions",
|
||||
workspace: "/Users/x/ws/.openclaw/extensions",
|
||||
stock: stockRoot,
|
||||
global: globalRoot,
|
||||
workspace: workspaceRoot,
|
||||
},
|
||||
);
|
||||
expect(out.value).toBe("stock:bluebubbles/index.ts");
|
||||
|
|
@ -19,15 +32,26 @@ describe("formatPluginSourceForTable", () => {
|
|||
});
|
||||
|
||||
it("shortens workspace plugin sources under the workspace root", () => {
|
||||
const stockRoot = path.resolve(
|
||||
path.sep,
|
||||
"opt",
|
||||
"homebrew",
|
||||
"lib",
|
||||
"node_modules",
|
||||
"openclaw",
|
||||
"extensions",
|
||||
);
|
||||
const globalRoot = path.resolve(path.sep, "Users", "x", ".openclaw", "extensions");
|
||||
const workspaceRoot = path.resolve(path.sep, "Users", "x", "ws", ".openclaw", "extensions");
|
||||
const out = formatPluginSourceForTable(
|
||||
{
|
||||
origin: "workspace",
|
||||
source: "/Users/x/ws/.openclaw/extensions/matrix/index.ts",
|
||||
source: path.join(workspaceRoot, "matrix", "index.ts"),
|
||||
},
|
||||
{
|
||||
stock: "/opt/homebrew/lib/node_modules/openclaw/extensions",
|
||||
global: "/Users/x/.openclaw/extensions",
|
||||
workspace: "/Users/x/ws/.openclaw/extensions",
|
||||
stock: stockRoot,
|
||||
global: globalRoot,
|
||||
workspace: workspaceRoot,
|
||||
},
|
||||
);
|
||||
expect(out.value).toBe("workspace:matrix/index.ts");
|
||||
|
|
@ -35,18 +59,57 @@ describe("formatPluginSourceForTable", () => {
|
|||
});
|
||||
|
||||
it("shortens global plugin sources under the global root", () => {
|
||||
const stockRoot = path.resolve(
|
||||
path.sep,
|
||||
"opt",
|
||||
"homebrew",
|
||||
"lib",
|
||||
"node_modules",
|
||||
"openclaw",
|
||||
"extensions",
|
||||
);
|
||||
const globalRoot = path.resolve(path.sep, "Users", "x", ".openclaw", "extensions");
|
||||
const workspaceRoot = path.resolve(path.sep, "Users", "x", "ws", ".openclaw", "extensions");
|
||||
const out = formatPluginSourceForTable(
|
||||
{
|
||||
origin: "global",
|
||||
source: "/Users/x/.openclaw/extensions/zalo/index.js",
|
||||
source: path.join(globalRoot, "zalo", "index.js"),
|
||||
},
|
||||
{
|
||||
stock: "/opt/homebrew/lib/node_modules/openclaw/extensions",
|
||||
global: "/Users/x/.openclaw/extensions",
|
||||
workspace: "/Users/x/ws/.openclaw/extensions",
|
||||
stock: stockRoot,
|
||||
global: globalRoot,
|
||||
workspace: workspaceRoot,
|
||||
},
|
||||
);
|
||||
expect(out.value).toBe("global:zalo/index.js");
|
||||
expect(out.rootKey).toBe("global");
|
||||
});
|
||||
|
||||
it("resolves source roots from an explicit env override", () => {
|
||||
const ignoredHome = path.resolve(path.sep, "tmp", "ignored-home");
|
||||
const homeDir = path.resolve(path.sep, "tmp", "openclaw-home");
|
||||
const roots = withEnv(
|
||||
{
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(ignoredHome, "ignored-bundled"),
|
||||
OPENCLAW_STATE_DIR: path.join(ignoredHome, "ignored-state"),
|
||||
HOME: ignoredHome,
|
||||
},
|
||||
() =>
|
||||
resolvePluginSourceRoots({
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: "~/bundled",
|
||||
OPENCLAW_STATE_DIR: "~/state",
|
||||
},
|
||||
workspaceDir: "~/ws",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(roots).toEqual({
|
||||
stock: path.join(homeDir, "bundled"),
|
||||
global: path.join(homeDir, "state", "extensions"),
|
||||
workspace: path.join(homeDir, "ws", ".openclaw", "extensions"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
import path from "node:path";
|
||||
import { resolveConfigDir, shortenHomeInString } from "../utils.js";
|
||||
import { resolveBundledPluginsDir } from "./bundled-dir.js";
|
||||
import { shortenHomeInString } from "../utils.js";
|
||||
import type { PluginRecord } from "./registry.js";
|
||||
|
||||
export type PluginSourceRoots = {
|
||||
stock?: string;
|
||||
global?: string;
|
||||
workspace?: string;
|
||||
};
|
||||
import type { PluginSourceRoots } from "./roots.js";
|
||||
export { resolvePluginSourceRoots } from "./roots.js";
|
||||
export type { PluginSourceRoots } from "./roots.js";
|
||||
|
||||
function tryRelative(root: string, filePath: string): string | null {
|
||||
const rel = path.relative(root, filePath);
|
||||
|
|
@ -27,15 +23,6 @@ function tryRelative(root: string, filePath: string): string | null {
|
|||
return rel.replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
export function resolvePluginSourceRoots(params: { workspaceDir?: string }): PluginSourceRoots {
|
||||
const stock = resolveBundledPluginsDir();
|
||||
const global = path.join(resolveConfigDir(), "extensions");
|
||||
const workspace = params.workspaceDir
|
||||
? path.join(params.workspaceDir, ".openclaw", "extensions")
|
||||
: undefined;
|
||||
return { stock, global, workspace };
|
||||
}
|
||||
|
||||
export function formatPluginSourceForTable(
|
||||
plugin: Pick<PluginRecord, "source" | "origin">,
|
||||
roots: PluginSourceRoots,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildPluginStatusReport } from "./status.js";
|
||||
|
||||
const loadConfigMock = vi.fn();
|
||||
const loadOpenClawPluginsMock = vi.fn();
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: () => loadConfigMock(),
|
||||
}));
|
||||
|
||||
vi.mock("./loader.js", () => ({
|
||||
loadOpenClawPlugins: (...args: unknown[]) => loadOpenClawPluginsMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../agents/agent-scope.js", () => ({
|
||||
resolveAgentWorkspaceDir: () => undefined,
|
||||
resolveDefaultAgentId: () => "default",
|
||||
}));
|
||||
|
||||
vi.mock("../agents/workspace.js", () => ({
|
||||
resolveDefaultAgentWorkspaceDir: () => "/default-workspace",
|
||||
}));
|
||||
|
||||
describe("buildPluginStatusReport", () => {
|
||||
beforeEach(() => {
|
||||
loadConfigMock.mockReset();
|
||||
loadOpenClawPluginsMock.mockReset();
|
||||
loadConfigMock.mockReturnValue({});
|
||||
loadOpenClawPluginsMock.mockReturnValue({
|
||||
plugins: [],
|
||||
diagnostics: [],
|
||||
channels: [],
|
||||
providers: [],
|
||||
tools: [],
|
||||
hooks: [],
|
||||
gatewayHandlers: {},
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards an explicit env to plugin loading", () => {
|
||||
const env = { HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv;
|
||||
|
||||
buildPluginStatusReport({
|
||||
config: {},
|
||||
workspaceDir: "/workspace",
|
||||
env,
|
||||
});
|
||||
|
||||
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: {},
|
||||
workspaceDir: "/workspace",
|
||||
env,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -15,6 +15,8 @@ const log = createSubsystemLogger("plugins");
|
|||
export function buildPluginStatusReport(params?: {
|
||||
config?: ReturnType<typeof loadConfig>;
|
||||
workspaceDir?: string;
|
||||
/** Use an explicit env when plugin roots should resolve independently from process.env. */
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): PluginStatusReport {
|
||||
const config = params?.config ?? loadConfig();
|
||||
const workspaceDir = params?.workspaceDir
|
||||
|
|
@ -25,6 +27,7 @@ export function buildPluginStatusReport(params?: {
|
|||
const registry = loadOpenClawPlugins({
|
||||
config,
|
||||
workspaceDir,
|
||||
env: params?.env,
|
||||
logger: createPluginLoaderLogger(log),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -153,4 +153,21 @@ describe("resolvePluginTools optional tools", () => {
|
|||
expect(tools.map((tool) => tool.name)).toEqual(["other_tool"]);
|
||||
expect(registry.diagnostics).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("forwards an explicit env to plugin loading", () => {
|
||||
setOptionalDemoRegistry();
|
||||
const env = { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv;
|
||||
|
||||
resolvePluginTools({
|
||||
context: createContext() as never,
|
||||
env,
|
||||
toolAllowlist: ["optional_tool"],
|
||||
});
|
||||
|
||||
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -47,10 +47,12 @@ export function resolvePluginTools(params: {
|
|||
existingToolNames?: Set<string>;
|
||||
toolAllowlist?: string[];
|
||||
suppressNameConflicts?: boolean;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): AnyAgentTool[] {
|
||||
// Fast path: when plugins are effectively disabled, avoid discovery/jiti entirely.
|
||||
// This matters a lot for unit tests and for tool construction hot paths.
|
||||
const effectiveConfig = applyTestPluginDefaults(params.context.config ?? {}, process.env);
|
||||
const env = params.env ?? process.env;
|
||||
const effectiveConfig = applyTestPluginDefaults(params.context.config ?? {}, env);
|
||||
const normalized = normalizePluginsConfig(effectiveConfig.plugins);
|
||||
if (!normalized.enabled) {
|
||||
return [];
|
||||
|
|
@ -59,6 +61,7 @@ export function resolvePluginTools(params: {
|
|||
const registry = loadOpenClawPlugins({
|
||||
config: effectiveConfig,
|
||||
workspaceDir: params.context.workspaceDir,
|
||||
env,
|
||||
logger: createPluginLoaderLogger(log),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -245,4 +245,79 @@ describe("syncPluginsForUpdateChannel", () => {
|
|||
});
|
||||
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards an explicit env to bundled plugin source resolution", async () => {
|
||||
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
|
||||
const env = { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv;
|
||||
|
||||
const { syncPluginsForUpdateChannel } = await import("./update.js");
|
||||
await syncPluginsForUpdateChannel({
|
||||
channel: "beta",
|
||||
config: {},
|
||||
workspaceDir: "/workspace",
|
||||
env,
|
||||
});
|
||||
|
||||
expect(resolveBundledPluginSourcesMock).toHaveBeenCalledWith({
|
||||
workspaceDir: "/workspace",
|
||||
env,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the provided env when matching bundled load and install paths", async () => {
|
||||
const bundledHome = "/tmp/openclaw-home";
|
||||
resolveBundledPluginSourcesMock.mockReturnValue(
|
||||
new Map([
|
||||
[
|
||||
"feishu",
|
||||
{
|
||||
pluginId: "feishu",
|
||||
localPath: `${bundledHome}/plugins/feishu`,
|
||||
npmSpec: "@openclaw/feishu",
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = "/tmp/process-home";
|
||||
try {
|
||||
const { syncPluginsForUpdateChannel } = await import("./update.js");
|
||||
const result = await syncPluginsForUpdateChannel({
|
||||
channel: "beta",
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_HOME: bundledHome,
|
||||
HOME: "/tmp/ignored-home",
|
||||
},
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: ["~/plugins/feishu"] },
|
||||
installs: {
|
||||
feishu: {
|
||||
source: "path",
|
||||
sourcePath: "~/plugins/feishu",
|
||||
installPath: "~/plugins/feishu",
|
||||
spec: "@openclaw/feishu",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.config.plugins?.load?.paths).toEqual(["~/plugins/feishu"]);
|
||||
expect(result.config.plugins?.installs?.feishu).toMatchObject({
|
||||
source: "path",
|
||||
sourcePath: "~/plugins/feishu",
|
||||
installPath: "~/plugins/feishu",
|
||||
});
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -123,21 +123,25 @@ async function readInstalledPackageVersion(dir: string): Promise<string | undefi
|
|||
}
|
||||
}
|
||||
|
||||
function pathsEqual(left?: string, right?: string): boolean {
|
||||
function pathsEqual(
|
||||
left: string | undefined,
|
||||
right: string | undefined,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
if (!left || !right) {
|
||||
return false;
|
||||
}
|
||||
return resolveUserPath(left) === resolveUserPath(right);
|
||||
return resolveUserPath(left, env) === resolveUserPath(right, env);
|
||||
}
|
||||
|
||||
function buildLoadPathHelpers(existing: string[]) {
|
||||
function buildLoadPathHelpers(existing: string[], env: NodeJS.ProcessEnv = process.env) {
|
||||
let paths = [...existing];
|
||||
const resolveSet = () => new Set(paths.map((entry) => resolveUserPath(entry)));
|
||||
const resolveSet = () => new Set(paths.map((entry) => resolveUserPath(entry, env)));
|
||||
let resolved = resolveSet();
|
||||
let changed = false;
|
||||
|
||||
const addPath = (value: string) => {
|
||||
const normalized = resolveUserPath(value);
|
||||
const normalized = resolveUserPath(value, env);
|
||||
if (resolved.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -147,11 +151,11 @@ function buildLoadPathHelpers(existing: string[]) {
|
|||
};
|
||||
|
||||
const removePath = (value: string) => {
|
||||
const normalized = resolveUserPath(value);
|
||||
const normalized = resolveUserPath(value, env);
|
||||
if (!resolved.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
paths = paths.filter((entry) => resolveUserPath(entry) !== normalized);
|
||||
paths = paths.filter((entry) => resolveUserPath(entry, env) !== normalized);
|
||||
resolved = resolveSet();
|
||||
changed = true;
|
||||
};
|
||||
|
|
@ -397,21 +401,26 @@ export async function syncPluginsForUpdateChannel(params: {
|
|||
config: OpenClawConfig;
|
||||
channel: UpdateChannel;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
logger?: PluginUpdateLogger;
|
||||
}): Promise<PluginChannelSyncResult> {
|
||||
const env = params.env ?? process.env;
|
||||
const summary: PluginChannelSyncSummary = {
|
||||
switchedToBundled: [],
|
||||
switchedToNpm: [],
|
||||
warnings: [],
|
||||
errors: [],
|
||||
};
|
||||
const bundled = resolveBundledPluginSources({ workspaceDir: params.workspaceDir });
|
||||
const bundled = resolveBundledPluginSources({
|
||||
workspaceDir: params.workspaceDir,
|
||||
env,
|
||||
});
|
||||
if (bundled.size === 0) {
|
||||
return { config: params.config, changed: false, summary };
|
||||
}
|
||||
|
||||
let next = params.config;
|
||||
const loadHelpers = buildLoadPathHelpers(next.plugins?.load?.paths ?? []);
|
||||
const loadHelpers = buildLoadPathHelpers(next.plugins?.load?.paths ?? [], env);
|
||||
const installs = next.plugins?.installs ?? {};
|
||||
let changed = false;
|
||||
|
||||
|
|
@ -425,7 +434,7 @@ export async function syncPluginsForUpdateChannel(params: {
|
|||
loadHelpers.addPath(bundledInfo.localPath);
|
||||
|
||||
const alreadyBundled =
|
||||
record.source === "path" && pathsEqual(record.sourcePath, bundledInfo.localPath);
|
||||
record.source === "path" && pathsEqual(record.sourcePath, bundledInfo.localPath, env);
|
||||
if (alreadyBundled) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -456,7 +465,7 @@ export async function syncPluginsForUpdateChannel(params: {
|
|||
if (record.source !== "path") {
|
||||
continue;
|
||||
}
|
||||
if (!pathsEqual(record.sourcePath, bundledInfo.localPath)) {
|
||||
if (!pathsEqual(record.sourcePath, bundledInfo.localPath, env)) {
|
||||
continue;
|
||||
}
|
||||
// Keep explicit bundled installs on release channels. Replacing them with
|
||||
|
|
@ -464,8 +473,8 @@ export async function syncPluginsForUpdateChannel(params: {
|
|||
loadHelpers.addPath(bundledInfo.localPath);
|
||||
const alreadyBundled =
|
||||
record.source === "path" &&
|
||||
pathsEqual(record.sourcePath, bundledInfo.localPath) &&
|
||||
pathsEqual(record.installPath, bundledInfo.localPath);
|
||||
pathsEqual(record.sourcePath, bundledInfo.localPath, env) &&
|
||||
pathsEqual(record.installPath, bundledInfo.localPath, env);
|
||||
if (alreadyBundled) {
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,6 +127,15 @@ describe("resolveConfigDir", () => {
|
|||
await fs.promises.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("expands OPENCLAW_STATE_DIR using the provided env", () => {
|
||||
const env = {
|
||||
HOME: "/tmp/openclaw-home",
|
||||
OPENCLAW_STATE_DIR: "~/state",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveConfigDir(env)).toBe(path.resolve("/tmp/openclaw-home", "state"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHomeDir", () => {
|
||||
|
|
@ -214,6 +223,15 @@ describe("resolveUserPath", () => {
|
|||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("uses the provided env for tilde expansion", () => {
|
||||
const env = {
|
||||
HOME: "/tmp/openclaw-home",
|
||||
OPENCLAW_HOME: "/srv/openclaw-home",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveUserPath("~/openclaw", env)).toBe(path.resolve("/srv/openclaw-home", "openclaw"));
|
||||
});
|
||||
|
||||
it("keeps blank paths blank", () => {
|
||||
expect(resolveUserPath("")).toBe("");
|
||||
expect(resolveUserPath(" ")).toBe("");
|
||||
|
|
|
|||
14
src/utils.ts
14
src/utils.ts
|
|
@ -271,7 +271,11 @@ export function truncateUtf16Safe(input: string, maxLen: number): string {
|
|||
return sliceUtf16Safe(input, 0, limit);
|
||||
}
|
||||
|
||||
export function resolveUserPath(input: string): string {
|
||||
export function resolveUserPath(
|
||||
input: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
homedir: () => string = os.homedir,
|
||||
): string {
|
||||
if (!input) {
|
||||
return "";
|
||||
}
|
||||
|
|
@ -281,9 +285,9 @@ export function resolveUserPath(input: string): string {
|
|||
}
|
||||
if (trimmed.startsWith("~")) {
|
||||
const expanded = expandHomePrefix(trimmed, {
|
||||
home: resolveRequiredHomeDir(process.env, os.homedir),
|
||||
env: process.env,
|
||||
homedir: os.homedir,
|
||||
home: resolveRequiredHomeDir(env, homedir),
|
||||
env,
|
||||
homedir,
|
||||
});
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
|
|
@ -296,7 +300,7 @@ export function resolveConfigDir(
|
|||
): string {
|
||||
const override = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
|
||||
if (override) {
|
||||
return resolveUserPath(override);
|
||||
return resolveUserPath(override, env, homedir);
|
||||
}
|
||||
const newDir = path.join(resolveRequiredHomeDir(env, homedir), ".openclaw");
|
||||
try {
|
||||
|
|
|
|||
Loading…
Reference in New Issue