mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix: isolate plugin discovery env from global state
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user