fix: isolate plugin discovery env from global state

This commit is contained in:
Peter Steinberger
2026-03-12 02:46:29 +00:00
parent 17fd46ab66
commit 43a10677ed
6 changed files with 70 additions and 87 deletions

View File

@@ -38,12 +38,15 @@ describe("config plugin validation", () => {
let enumPluginDir = "";
let bluebubblesPluginDir = "";
let voiceCallSchemaPluginDir = "";
const envSnapshot = {
OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: process.env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS,
};
const suiteEnv = () =>
({
...process.env,
OPENCLAW_STATE_DIR: path.join(suiteHome, ".openclaw"),
OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: "10000",
}) satisfies NodeJS.ProcessEnv;
const validateInSuite = (raw: unknown) => validateConfigObjectWithPlugins(raw);
const validateInSuite = (raw: unknown) =>
validateConfigObjectWithPlugins(raw, { env: suiteEnv() });
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-plugin-validation-"));
@@ -102,8 +105,6 @@ describe("config plugin validation", () => {
id: "voice-call-schema-fixture",
schema: voiceCallManifest.configSchema,
});
process.env.OPENCLAW_STATE_DIR = path.join(suiteHome, ".openclaw");
process.env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS = "10000";
clearPluginManifestRegistryCache();
// Warm the plugin manifest cache once so path-based validations can reuse
// parsed manifests across test cases.
@@ -118,16 +119,6 @@ describe("config plugin validation", () => {
afterAll(async () => {
await fs.rm(fixtureRoot, { recursive: true, force: true });
clearPluginManifestRegistryCache();
if (envSnapshot.OPENCLAW_STATE_DIR === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = envSnapshot.OPENCLAW_STATE_DIR;
}
if (envSnapshot.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS === undefined) {
delete process.env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS;
} else {
process.env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS = envSnapshot.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS;
}
});
it("reports missing plugin refs across load paths, entries, and allowlist surfaces", async () => {

View File

@@ -297,17 +297,23 @@ type ValidateConfigWithPluginsResult =
warnings: ConfigValidationIssue[];
};
export function validateConfigObjectWithPlugins(raw: unknown): ValidateConfigWithPluginsResult {
return validateConfigObjectWithPluginsBase(raw, { applyDefaults: true });
export function validateConfigObjectWithPlugins(
raw: unknown,
params?: { env?: NodeJS.ProcessEnv },
): ValidateConfigWithPluginsResult {
return validateConfigObjectWithPluginsBase(raw, { applyDefaults: true, env: params?.env });
}
export function validateConfigObjectRawWithPlugins(raw: unknown): ValidateConfigWithPluginsResult {
return validateConfigObjectWithPluginsBase(raw, { applyDefaults: false });
export function validateConfigObjectRawWithPlugins(
raw: unknown,
params?: { env?: NodeJS.ProcessEnv },
): ValidateConfigWithPluginsResult {
return validateConfigObjectWithPluginsBase(raw, { applyDefaults: false, env: params?.env });
}
function validateConfigObjectWithPluginsBase(
raw: unknown,
opts: { applyDefaults: boolean },
opts: { applyDefaults: boolean; env?: NodeJS.ProcessEnv },
): ValidateConfigWithPluginsResult {
const base = opts.applyDefaults ? validateConfigObject(raw) : validateConfigObjectRaw(raw);
if (!base.ok) {
@@ -345,6 +351,7 @@ function validateConfigObjectWithPluginsBase(
const registry = loadPluginManifestRegistry({
config,
workspaceDir: workspaceDir ?? undefined,
env: opts.env,
});
for (const diag of registry.diagnostics) {

View File

@@ -2,8 +2,8 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
export function resolveBundledPluginsDir(): string | undefined {
const override = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR?.trim();
export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): string | undefined {
const override = env.OPENCLAW_BUNDLED_PLUGINS_DIR?.trim();
if (override) {
return override;
}

View File

@@ -3,7 +3,6 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
import { clearPluginDiscoveryCache, discoverOpenClawPlugins } from "./discovery.js";
const tempDirs: string[] = [];
@@ -15,24 +14,20 @@ function makeTempDir() {
return dir;
}
async function withStateDir<T>(stateDir: string, fn: () => Promise<T>) {
return await withEnvAsync(
{
OPENCLAW_STATE_DIR: stateDir,
CLAWDBOT_STATE_DIR: undefined,
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
},
fn,
);
function buildDiscoveryEnv(stateDir: string): NodeJS.ProcessEnv {
return {
...process.env,
OPENCLAW_STATE_DIR: stateDir,
CLAWDBOT_STATE_DIR: undefined,
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
};
}
async function discoverWithStateDir(
stateDir: string,
params: Parameters<typeof discoverOpenClawPlugins>[0],
) {
return await withStateDir(stateDir, async () => {
return discoverOpenClawPlugins(params);
});
return discoverOpenClawPlugins({ ...params, env: buildDiscoveryEnv(stateDir) });
}
function writePluginPackageManifest(params: {
@@ -80,9 +75,7 @@ describe("discoverOpenClawPlugins", () => {
fs.mkdirSync(workspaceExt, { recursive: true });
fs.writeFileSync(path.join(workspaceExt, "beta.ts"), "export default function () {}", "utf-8");
const { candidates } = await withStateDir(stateDir, async () => {
return discoverOpenClawPlugins({ workspaceDir });
});
const { candidates } = await discoverWithStateDir(stateDir, { workspaceDir });
const ids = candidates.map((c) => c.idHint);
expect(ids).toContain("alpha");
@@ -110,9 +103,7 @@ describe("discoverOpenClawPlugins", () => {
fs.mkdirSync(liveDir, { recursive: true });
fs.writeFileSync(path.join(liveDir, "index.ts"), "export default function () {}", "utf-8");
const { candidates } = await withStateDir(stateDir, async () => {
return discoverOpenClawPlugins({});
});
const { candidates } = await discoverWithStateDir(stateDir, {});
const ids = candidates.map((candidate) => candidate.idHint);
expect(ids).toContain("live");
@@ -142,9 +133,7 @@ describe("discoverOpenClawPlugins", () => {
"utf-8",
);
const { candidates } = await withStateDir(stateDir, async () => {
return discoverOpenClawPlugins({});
});
const { candidates } = await discoverWithStateDir(stateDir, {});
const ids = candidates.map((c) => c.idHint);
expect(ids).toContain("pack/one");
@@ -167,9 +156,7 @@ describe("discoverOpenClawPlugins", () => {
"utf-8",
);
const { candidates } = await withStateDir(stateDir, async () => {
return discoverOpenClawPlugins({});
});
const { candidates } = await discoverWithStateDir(stateDir, {});
const ids = candidates.map((c) => c.idHint);
expect(ids).toContain("voice-call");
@@ -187,9 +174,7 @@ describe("discoverOpenClawPlugins", () => {
});
fs.writeFileSync(path.join(packDir, "index.js"), "module.exports = {}", "utf-8");
const { candidates } = await withStateDir(stateDir, async () => {
return discoverOpenClawPlugins({ extraPaths: [packDir] });
});
const { candidates } = await discoverWithStateDir(stateDir, { extraPaths: [packDir] });
const ids = candidates.map((c) => c.idHint);
expect(ids).toContain("demo-plugin-dir");
@@ -266,9 +251,7 @@ describe("discoverOpenClawPlugins", () => {
extensions: ["./escape.ts"],
});
const { candidates, diagnostics } = await withStateDir(stateDir, async () => {
return discoverOpenClawPlugins({});
});
const { candidates, diagnostics } = await discoverWithStateDir(stateDir, {});
expect(candidates.some((candidate) => candidate.idHint === "pack")).toBe(false);
expectEscapesPackageDiagnostic(diagnostics);
@@ -303,9 +286,7 @@ describe("discoverOpenClawPlugins", () => {
throw err;
}
const { candidates } = await withStateDir(stateDir, async () => {
return discoverOpenClawPlugins({});
});
const { candidates } = await discoverWithStateDir(stateDir, {});
expect(candidates.some((candidate) => candidate.idHint === "pack")).toBe(false);
});
@@ -318,9 +299,7 @@ describe("discoverOpenClawPlugins", () => {
fs.writeFileSync(pluginPath, "export default function () {}", "utf-8");
fs.chmodSync(pluginPath, 0o777);
const result = await withStateDir(stateDir, async () => {
return discoverOpenClawPlugins({});
});
const result = await discoverWithStateDir(stateDir, {});
expect(result.candidates).toHaveLength(0);
expect(result.diagnostics.some((diag) => diag.message.includes("world-writable path"))).toBe(
@@ -338,14 +317,14 @@ describe("discoverOpenClawPlugins", () => {
fs.writeFileSync(path.join(packDir, "index.ts"), "export default function () {}", "utf-8");
fs.chmodSync(packDir, 0o777);
const result = await withEnvAsync(
{
const result = discoverOpenClawPlugins({
env: {
...process.env,
OPENCLAW_STATE_DIR: stateDir,
CLAWDBOT_STATE_DIR: undefined,
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir,
},
async () => discoverOpenClawPlugins({}),
);
});
expect(result.candidates.some((candidate) => candidate.idHint === "demo-pack")).toBe(true);
expect(
@@ -370,9 +349,7 @@ describe("discoverOpenClawPlugins", () => {
);
const actualUid = (process as NodeJS.Process & { getuid: () => number }).getuid();
const result = await withStateDir(stateDir, async () => {
return discoverOpenClawPlugins({ ownershipUid: actualUid + 1 });
});
const result = await discoverWithStateDir(stateDir, { ownershipUid: actualUid + 1 });
const shouldBlockForMismatch = actualUid !== 0;
expect(result.candidates).toHaveLength(shouldBlockForMismatch ? 0 : 1);
expect(result.diagnostics.some((diag) => diag.message.includes("suspicious ownership"))).toBe(
@@ -388,32 +365,32 @@ describe("discoverOpenClawPlugins", () => {
const pluginPath = path.join(globalExt, "cached.ts");
fs.writeFileSync(pluginPath, "export default function () {}", "utf-8");
const first = await withEnvAsync(
{
const first = discoverOpenClawPlugins({
env: {
...buildDiscoveryEnv(stateDir),
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000",
},
async () => withStateDir(stateDir, async () => discoverOpenClawPlugins({})),
);
});
expect(first.candidates.some((candidate) => candidate.idHint === "cached")).toBe(true);
fs.rmSync(pluginPath, { force: true });
const second = await withEnvAsync(
{
const second = discoverOpenClawPlugins({
env: {
...buildDiscoveryEnv(stateDir),
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000",
},
async () => withStateDir(stateDir, async () => discoverOpenClawPlugins({})),
);
});
expect(second.candidates.some((candidate) => candidate.idHint === "cached")).toBe(true);
clearPluginDiscoveryCache();
const third = await withEnvAsync(
{
const third = discoverOpenClawPlugins({
env: {
...buildDiscoveryEnv(stateDir),
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000",
},
async () => withStateDir(stateDir, async () => discoverOpenClawPlugins({})),
);
});
expect(third.candidates.some((candidate) => candidate.idHint === "cached")).toBe(false);
});
});

View File

@@ -69,10 +69,11 @@ function buildDiscoveryCacheKey(params: {
workspaceDir?: string;
extraPaths?: string[];
ownershipUid?: number | null;
env: NodeJS.ProcessEnv;
}): string {
const workspaceKey = params.workspaceDir ? resolveUserPath(params.workspaceDir) : "";
const configExtensionsRoot = path.join(resolveConfigDir(), "extensions");
const bundledRoot = resolveBundledPluginsDir() ?? "";
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())
@@ -649,6 +650,7 @@ export function discoverOpenClawPlugins(params: {
workspaceDir: params.workspaceDir,
extraPaths: params.extraPaths,
ownershipUid: params.ownershipUid,
env,
});
if (cacheEnabled) {
const cached = discoveryCache.get(cacheKey);
@@ -697,7 +699,7 @@ export function discoverOpenClawPlugins(params: {
}
}
const bundledDir = resolveBundledPluginsDir();
const bundledDir = resolveBundledPluginsDir(env);
if (bundledDir) {
discoverInDirectory({
dir: bundledDir,
@@ -711,7 +713,7 @@ 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(), "extensions");
const globalDir = path.join(resolveConfigDir(env), "extensions");
discoverInDirectory({
dir: globalDir,
origin: "global",

View File

@@ -1,6 +1,8 @@
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import { resolveUserPath } from "../utils.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";
@@ -79,8 +81,11 @@ function shouldUseManifestCache(env: NodeJS.ProcessEnv): boolean {
function buildCacheKey(params: {
workspaceDir?: string;
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) ?? "";
// 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
@@ -88,7 +93,7 @@ function buildCacheKey(params: {
.map((p) => p.trim())
.filter(Boolean)
.toSorted();
return `${workspaceKey}::${JSON.stringify(loadPaths)}`;
return `${workspaceKey}::${configExtensionsRoot}::${bundledRoot}::${JSON.stringify(loadPaths)}`;
}
function safeStatMtimeMs(filePath: string): number | null {
@@ -142,8 +147,8 @@ export function loadPluginManifestRegistry(params: {
}): PluginManifestRegistry {
const config = params.config ?? {};
const normalized = normalizePluginsConfig(config.plugins);
const cacheKey = buildCacheKey({ workspaceDir: params.workspaceDir, plugins: normalized });
const env = params.env ?? process.env;
const cacheKey = buildCacheKey({ workspaceDir: params.workspaceDir, plugins: normalized, env });
const cacheEnabled = params.cache !== false && shouldUseManifestCache(env);
if (cacheEnabled) {
const cached = registryCache.get(cacheKey);
@@ -160,6 +165,7 @@ export function loadPluginManifestRegistry(params: {
: discoverOpenClawPlugins({
workspaceDir: params.workspaceDir,
extraPaths: normalized.loadPaths,
env,
});
const diagnostics: PluginDiagnostic[] = [...discovery.diagnostics];
const candidates: PluginCandidate[] = discovery.candidates;