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:
Gustavo Madeira Santana 2026-03-12 15:31:31 +00:00 committed by GitHub
parent 688e3f0863
commit e6897c800b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1423 additions and 151 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: [

View File

@ -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,

View File

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

View File

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

View File

@ -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"]);
});
});

View File

@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

46
src/plugins/roots.ts Normal file
View File

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

View File

@ -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"),
});
});
});

View File

@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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