diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index 99438a13e16..464a5f37ced 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -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 () => { diff --git a/src/config/validation.ts b/src/config/validation.ts index 90d733e0818..686dbb0ed43 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -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) { diff --git a/src/plugins/bundled-dir.ts b/src/plugins/bundled-dir.ts index 4837ae59dc9..09f28bcdc19 100644 --- a/src/plugins/bundled-dir.ts +++ b/src/plugins/bundled-dir.ts @@ -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; } diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 4c8933f1d1a..00430037b86 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -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(stateDir: string, fn: () => Promise) { - 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[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); }); }); diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 36d7d4792f4..686c1f7fd86 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -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", diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index d392144f925..eb6702d54b1 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -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;