From e6897c800bbf74d730d130ac0197a72cc9562e4d Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 12 Mar 2026 15:31:31 +0000 Subject: [PATCH] Plugins: fix env-aware root resolution and caching (#44046) Merged via squash. Prepared head SHA: 6e8852a188b0eaa4d6cf0bb71829023e0e0ed82b Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + src/channels/plugins/catalog.ts | 31 +- src/channels/plugins/plugins-core.test.ts | 76 +++++ src/config/plugin-auto-enable.test.ts | 141 +++++++- src/config/plugin-auto-enable.ts | 34 +- src/plugins/bundled-dir.ts | 3 +- src/plugins/bundled-sources.test.ts | 28 ++ src/plugins/bundled-sources.ts | 14 +- src/plugins/cli.test.ts | 49 ++- src/plugins/cli.ts | 7 +- src/plugins/discovery.test.ts | 110 +++++++ src/plugins/discovery.ts | 61 ++-- src/plugins/loader.test.ts | 378 ++++++++++++++++++++++ src/plugins/loader.ts | 97 +++++- src/plugins/manifest-registry.test.ts | 104 +++++- src/plugins/manifest-registry.ts | 20 +- src/plugins/providers.test.ts | 34 ++ src/plugins/providers.ts | 3 + src/plugins/roots.ts | 46 +++ src/plugins/source-display.test.ts | 89 ++++- src/plugins/source-display.ts | 21 +- src/plugins/status.test.ts | 60 ++++ src/plugins/status.ts | 3 + src/plugins/tools.optional.test.ts | 17 + src/plugins/tools.ts | 5 +- src/plugins/update.test.ts | 75 +++++ src/plugins/update.ts | 35 +- src/utils.test.ts | 18 ++ src/utils.ts | 14 +- 29 files changed, 1423 insertions(+), 151 deletions(-) create mode 100644 src/plugins/providers.test.ts create mode 100644 src/plugins/roots.ts create mode 100644 src/plugins/status.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fb840ea5cd..19ba26c0e19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index fe2208765e3..a853dcdf805 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -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 = { @@ -51,12 +52,6 @@ type ExternalCatalogEntry = { description?: string; } & Partial>; -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(); for (const candidate of discovery.candidates) { diff --git a/src/channels/plugins/plugins-core.test.ts b/src/channels/plugins/plugins-core.test.ts index 4e346f465bd..9ccbaac8946 100644 --- a/src/channels/plugins/plugins-core.test.ts +++ b/src/channels/plugins/plugins-core.test.ts @@ -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([]); diff --git a/src/config/plugin-auto-enable.test.ts b/src/config/plugin-auto-enable.test.ts index 52b2c9cc180..da358084db3 100644 --- a/src/config/plugin-auto-enable.test.ts +++ b/src/config/plugin-auto-enable.test.ts @@ -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(); + }); }); }); diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index eccb6f980ed..5c365fb5cc8 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -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(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(listKnownChannelPluginIds(env)); const configuredChannels = cfg.channels as Record | 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; diff --git a/src/plugins/bundled-dir.ts b/src/plugins/bundled-dir.ts index 09f28bcdc19..89d43444640 100644 --- a/src/plugins/bundled-dir.ts +++ b/src/plugins/bundled-dir.ts @@ -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. diff --git a/src/plugins/bundled-sources.test.ts b/src/plugins/bundled-sources.test.ts index 691dec466fd..e853e4c3a3c 100644 --- a/src/plugins/bundled-sources.test.ts +++ b/src/plugins/bundled-sources.test.ts @@ -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: [ diff --git a/src/plugins/bundled-sources.ts b/src/plugins/bundled-sources.ts index a011227c278..57745c58388 100644 --- a/src/plugins/bundled-sources.ts +++ b/src/plugins/bundled-sources.ts @@ -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 { - const discovery = discoverOpenClawPlugins({ workspaceDir: params.workspaceDir }); + const discovery = discoverOpenClawPlugins({ + workspaceDir: params.workspaceDir, + env: params.env, + }); const bundled = new Map(); 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, diff --git a/src/plugins/cli.test.ts b/src/plugins/cli.test.ts index 22a75e4cbe6..403b4131eed 100644 --- a/src/plugins/cli.test.ts +++ b/src/plugins/cli.test.ts @@ -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, + }), + ); + }); }); diff --git a/src/plugins/cli.ts b/src/plugins/cli.ts index c96eeca4d53..4d8af51e3db 100644 --- a/src/plugins/cli.ts +++ b/src/plugins/cli.ts @@ -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, }); diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 00430037b86..c771b17a957 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -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"]); + }); }); diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 686c1f7fd86..398a202d153 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -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; }) { - 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(); 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, diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index cff49aa8a19..00af213be45 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -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({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 41a2f0fa3f8..40983b43347 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -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; runtimeOptions?: CreatePluginRuntimeOptions; @@ -44,8 +49,13 @@ export type PluginLoadOptions = { mode?: "full" | "validate"; }; +const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 32; const registryCache = new Map(); +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; + 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(), 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(); @@ -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; diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 9212c6fcf05..7d5421b1a35 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -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)); + }); }); diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index eb6702d54b1..7b6a0ca4bfb 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -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)}`; } diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts new file mode 100644 index 00000000000..26c70df090a --- /dev/null +++ b/src/plugins/providers.test.ts @@ -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, + }), + ); + }); +}); diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 60d54d321bd..788a28ca805 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -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), }); diff --git a/src/plugins/roots.ts b/src/plugins/roots.ts new file mode 100644 index 00000000000..1b74f6c5d9b --- /dev/null +++ b/src/plugins/roots.ts @@ -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 }; +} diff --git a/src/plugins/source-display.test.ts b/src/plugins/source-display.test.ts index c555f627d68..3c85cca88b7 100644 --- a/src/plugins/source-display.test.ts +++ b/src/plugins/source-display.test.ts @@ -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"), + }); + }); }); diff --git a/src/plugins/source-display.ts b/src/plugins/source-display.ts index c6bad9f3fee..8e955d08edc 100644 --- a/src/plugins/source-display.ts +++ b/src/plugins/source-display.ts @@ -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, roots: PluginSourceRoots, diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts new file mode 100644 index 00000000000..c93ce5ef37b --- /dev/null +++ b/src/plugins/status.test.ts @@ -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, + }), + ); + }); +}); diff --git a/src/plugins/status.ts b/src/plugins/status.ts index b136366eb4a..65c48203eb8 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -15,6 +15,8 @@ const log = createSubsystemLogger("plugins"); export function buildPluginStatusReport(params?: { config?: ReturnType; 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), }); diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index da2ba912ab7..20e68f0ca66 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -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, + }), + ); + }); }); diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index 055f092416f..ebf96ec6a4c 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -47,10 +47,12 @@ export function resolvePluginTools(params: { existingToolNames?: Set; 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), }); diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 07a2b6555d7..65ef9966a83 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -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; + } + } + }); }); diff --git a/src/plugins/update.ts b/src/plugins/update.ts index a17c34b90b8..b214558bc57 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -123,21 +123,25 @@ async function readInstalledPackageVersion(dir: string): Promise 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 { + 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; } diff --git a/src/utils.test.ts b/src/utils.test.ts index 0f4823c4019..def788d198a 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -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(""); diff --git a/src/utils.ts b/src/utils.ts index cb044d05b69..38c26605b19 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -271,7 +271,11 @@ export function truncateUtf16Safe(input: string, maxLen: number): string { return sliceUtf16Safe(input, 0, limit); } -export function resolveUserPath(input: string): string { +export function resolveUserPath( + input: string, + env: NodeJS.ProcessEnv = process.env, + homedir: () => string = os.homedir, +): string { if (!input) { return ""; } @@ -281,9 +285,9 @@ export function resolveUserPath(input: string): string { } if (trimmed.startsWith("~")) { const expanded = expandHomePrefix(trimmed, { - home: resolveRequiredHomeDir(process.env, os.homedir), - env: process.env, - homedir: os.homedir, + home: resolveRequiredHomeDir(env, homedir), + env, + homedir, }); return path.resolve(expanded); } @@ -296,7 +300,7 @@ export function resolveConfigDir( ): string { const override = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim(); if (override) { - return resolveUserPath(override); + return resolveUserPath(override, env, homedir); } const newDir = path.join(resolveRequiredHomeDir(env, homedir), ".openclaw"); try {