diff --git a/docs/cli/agent.md b/docs/cli/agent.md
index b526d1e0a8f2..98d0c5eb7354 100644
--- a/docs/cli/agent.md
+++ b/docs/cli/agent.md
@@ -30,7 +30,7 @@ Config is layered in three parts, entirely in memory: exec composes the run conf
Use `--state-dir
` to retain sessions and other run state. The directory must already exist and is never created or deleted by the command.
-The state directory is also where installed plugins live, so the default ephemeral one cannot discover plugins you installed with `openclaw plugins install`. If your config selects a provider, channel, or harness from a non-bundled plugin, point the run at your real state directory with `--state-dir ~/.openclaw`.
+When exec uses the ambient or a pinned config, installed plugins continue to resolve from the operator's ordinary plugin roots while sessions and other run state use the ephemeral directory. In those modes, `--state-dir` controls run state only; it is not required for configured providers, channels, or harnesses supplied by installed plugins.
For reproducible runs, pin the config instead of inheriting it. `--config ` runs against exactly that config file, read through the normal loader so JSON5 syntax and `$include` resolve relative to it; a missing or invalid file fails the run rather than falling back to defaults, as does an ambient config that exists but cannot be parsed. `--isolated` ignores the ambient config entirely and uses only the exec defaults above. Both are the right choice for CI, where inheriting operator state would make runs machine-dependent.
diff --git a/src/cli/plugins-uninstall-command.ts b/src/cli/plugins-uninstall-command.ts
index 518d1d61a9b4..bc9e6acc7ea2 100644
--- a/src/cli/plugins-uninstall-command.ts
+++ b/src/cli/plugins-uninstall-command.ts
@@ -1,15 +1,13 @@
// Plugin uninstall command implementation and confirmation-driven removal plan execution.
-import os from "node:os";
-import path from "node:path";
import { theme } from "../../packages/terminal-core/src/theme.js";
import {
assertConfigWriteAllowedInCurrentMode,
readConfigFileSnapshotForWrite,
replaceConfigFile,
} from "../config/config.js";
-import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { parseClawHubPluginSpec } from "../infra/clawhub.js";
+import { resolveDefaultPluginExtensionsDir } from "../plugins/install-paths.js";
import { withPluginLifecycleLease } from "../plugins/plugin-lifecycle-lease.js";
import {
tracePluginLifecyclePhase,
@@ -109,7 +107,7 @@ async function runPluginUninstallCommandUnlocked(
() => buildPluginSnapshotReport({ config: cfg }),
{ command: "uninstall" },
);
- const extensionsDir = path.join(resolveStateDir(process.env, os.homedir), "extensions");
+ const extensionsDir = resolveDefaultPluginExtensionsDir();
const keepFiles = Boolean(opts.keepFiles || opts.keepConfig);
if (opts.keepConfig) {
diff --git a/src/commands/agent-exec-plugin.e2e.test.ts b/src/commands/agent-exec-plugin.e2e.test.ts
new file mode 100644
index 000000000000..0b0c4d6edcde
--- /dev/null
+++ b/src/commands/agent-exec-plugin.e2e.test.ts
@@ -0,0 +1,214 @@
+import { execFile } from "node:child_process";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { promisify } from "node:util";
+import { afterEach, describe, expect, it } from "vitest";
+
+const execFileAsync = promisify(execFile);
+const tempRoots: string[] = [];
+
+async function makeTempRoot(): Promise {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-exec-plugin-e2e-"));
+ tempRoots.push(root);
+ return root;
+}
+
+async function writeHarnessPlugin(stateDir: string): Promise {
+ const pluginDir = path.join(stateDir, "extensions", "exec-proof");
+ await fs.mkdir(pluginDir, { recursive: true });
+ await fs.writeFile(
+ path.join(pluginDir, "openclaw.plugin.json"),
+ JSON.stringify({
+ id: "exec-proof",
+ name: "Agent exec proof harness",
+ activation: { onStartup: false, onAgentHarnesses: ["exec-proof"] },
+ configSchema: { type: "object", additionalProperties: false },
+ }),
+ "utf8",
+ );
+ await fs.writeFile(
+ path.join(pluginDir, "package.json"),
+ JSON.stringify({
+ name: "exec-proof",
+ version: "1.0.0",
+ type: "module",
+ openclaw: { extensions: ["./index.js"] },
+ }),
+ "utf8",
+ );
+ await fs.writeFile(
+ path.join(pluginDir, "index.js"),
+ `export default {
+ id: "exec-proof",
+ register(api) {
+ api.registerAgentHarness({
+ id: "exec-proof",
+ label: "Agent exec proof harness",
+ authBootstrap: "harness",
+ supports: ({ provider }) => provider === "exec-proof"
+ ? { supported: true, priority: 100 }
+ : { supported: false },
+ async runAttempt() {
+ const text = "PLUGIN_HARNESS_OK";
+ const assistant = {
+ role: "assistant",
+ content: [{ type: "text", text }],
+ api: "openai-responses",
+ provider: "exec-proof",
+ model: "proof-model",
+ usage: {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 0,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ stopReason: "stop",
+ timestamp: Date.now(),
+ };
+ return {
+ terminal: { kind: "ok" },
+ sessionIdUsed: "exec-proof-session",
+ messagesSnapshot: [assistant],
+ assistantTexts: [text],
+ toolMetas: [],
+ lastAssistant: assistant,
+ didSendViaMessagingTool: false,
+ messagingToolSentTexts: [],
+ messagingToolSentMediaUrls: [],
+ messagingToolSentTargets: [],
+ cloudCodeAssistFormatError: false,
+ replayMetadata: { hadPotentialSideEffects: false, replaySafe: true },
+ itemLifecycle: { startedCount: 0, completedCount: 0, activeCount: 0 },
+ };
+ },
+ });
+ },
+ };\n`,
+ "utf8",
+ );
+}
+
+async function writeConfig(stateDir: string): Promise {
+ await fs.writeFile(
+ path.join(stateDir, "openclaw.json"),
+ JSON.stringify({
+ plugins: {
+ allow: ["exec-proof"],
+ entries: { "exec-proof": { enabled: true } },
+ },
+ models: {
+ providers: {
+ "exec-proof": {
+ api: "openai-responses",
+ baseUrl: "https://example.invalid/v1",
+ models: [
+ {
+ id: "proof-model",
+ name: "Proof model",
+ contextWindow: 128000,
+ maxTokens: 4096,
+ agentRuntime: { id: "exec-proof" },
+ },
+ ],
+ },
+ },
+ },
+ agents: { defaults: { model: { primary: "exec-proof/proof-model" } } },
+ }),
+ "utf8",
+ );
+}
+
+function buildCliSource(args: string[]): string {
+ return `
+ import { runMainOrRootHelp } from "./src/entry.ts";
+ await runMainOrRootHelp(${JSON.stringify(["node", "openclaw", ...args])});
+ `;
+}
+
+afterEach(async () => {
+ await Promise.all(
+ tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })),
+ );
+});
+
+describe("agent exec installed plugin isolation", () => {
+ it("runs an operator-installed harness without retaining run state", async () => {
+ const stateDir = await makeTempRoot();
+ await writeHarnessPlugin(stateDir);
+ await writeConfig(stateDir);
+ const source = buildCliSource(["agent", "exec", "prove plugin discovery", "--json"]);
+ const childEnv: NodeJS.ProcessEnv = {
+ ...process.env,
+ OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
+ OPENCLAW_STATE_DIR: stateDir,
+ };
+ delete childEnv.NODE_ENV;
+ delete childEnv.OPENCLAW_RUN_NODE_OUTPUT_LOG;
+ delete childEnv.VITEST;
+ delete childEnv.VITEST_POOL_ID;
+ delete childEnv.VITEST_WORKER_ID;
+
+ const { stdout, stderr } = await execFileAsync(
+ process.execPath,
+ ["--import", "tsx", "--input-type=module", "--eval", source],
+ {
+ cwd: path.resolve(import.meta.dirname, "../.."),
+ encoding: "utf8",
+ env: childEnv,
+ timeout: 30_000,
+ },
+ );
+
+ expect(stdout, stderr).not.toBe("");
+ expect(JSON.parse(stdout)).toMatchObject({
+ ok: true,
+ status: "ok",
+ final: "PLUGIN_HARNESS_OK",
+ model: "proof-model",
+ provider: "exec-proof",
+ });
+ let isolatedExitCode: number | undefined;
+ let isolatedStdout = "";
+ try {
+ await execFileAsync(
+ process.execPath,
+ [
+ "--import",
+ "tsx",
+ "--input-type=module",
+ "--eval",
+ buildCliSource([
+ "agent",
+ "exec",
+ "prove isolated discovery",
+ "--isolated",
+ "--model",
+ "exec-proof/proof-model",
+ "--json",
+ ]),
+ ],
+ {
+ cwd: path.resolve(import.meta.dirname, "../.."),
+ encoding: "utf8",
+ env: childEnv,
+ timeout: 30_000,
+ },
+ );
+ } catch (error) {
+ const failure = error as Error & { code?: number; stdout?: string };
+ isolatedExitCode = failure.code;
+ isolatedStdout = failure.stdout ?? "";
+ }
+ expect(isolatedExitCode).toBe(1);
+ expect(isolatedStdout).not.toContain("PLUGIN_HARNESS_OK");
+ await expect(fs.readdir(stateDir)).resolves.toEqual(["extensions", "openclaw.json", "state"]);
+ const registryFiles = await fs.readdir(path.join(stateDir, "state"));
+ expect(registryFiles).toContain("openclaw.sqlite");
+ expect(registryFiles.every((file) => file.startsWith("openclaw.sqlite"))).toBe(true);
+ await expect(fs.stat(path.join(stateDir, "agents"))).rejects.toMatchObject({ code: "ENOENT" });
+ });
+});
diff --git a/src/commands/agent-exec.test.ts b/src/commands/agent-exec.test.ts
index 2f96f708767e..ee0b94316ca6 100644
--- a/src/commands/agent-exec.test.ts
+++ b/src/commands/agent-exec.test.ts
@@ -333,6 +333,118 @@ describe("agent exec command composition", () => {
await expect(fs.stat(observedStateDir)).rejects.toMatchObject({ code: "ENOENT" });
});
+ it("discovers operator-installed plugins while run state stays ephemeral", async () => {
+ const operatorStateDir = await makeTempRoot("openclaw-agent-exec-plugin-owner-");
+ const pluginDir = path.join(operatorStateDir, "extensions", "exec-provider");
+ await fs.mkdir(pluginDir, { recursive: true });
+ await fs.writeFile(
+ path.join(pluginDir, "openclaw.plugin.json"),
+ JSON.stringify({
+ id: "exec-provider",
+ configSchema: { type: "object", additionalProperties: false },
+ providers: ["exec-provider"],
+ }),
+ "utf8",
+ );
+ await fs.writeFile(
+ path.join(pluginDir, "package.json"),
+ JSON.stringify({
+ name: "exec-provider",
+ version: "1.0.0",
+ type: "module",
+ openclaw: { extensions: ["./index.js"] },
+ }),
+ "utf8",
+ );
+ await fs.writeFile(path.join(pluginDir, "index.js"), "export default {}\n", "utf8");
+ const previousStateDir = process.env.OPENCLAW_STATE_DIR;
+ process.env.OPENCLAW_STATE_DIR = operatorStateDir;
+ const { runtime } = createRuntime();
+ let runtimeStateDir = "";
+ let discoveredRoot = "";
+ try {
+ await agentExecCommand("inspect", {}, runtime, {
+ runAgent: vi.fn(async () => {
+ runtimeStateDir = process.env.OPENCLAW_STATE_DIR ?? "";
+ const { resolvePluginMetadataSnapshot } =
+ await import("../plugins/plugin-metadata-snapshot.js");
+ const snapshot = resolvePluginMetadataSnapshot({
+ allowCurrent: false,
+ config: { plugins: { entries: { "exec-provider": { enabled: true } } } },
+ env: process.env,
+ preferPersisted: false,
+ });
+ discoveredRoot = snapshot.byPluginId.get("exec-provider")?.rootDir ?? "";
+ return successResult();
+ }),
+ });
+ } finally {
+ if (previousStateDir === undefined) {
+ delete process.env.OPENCLAW_STATE_DIR;
+ } else {
+ process.env.OPENCLAW_STATE_DIR = previousStateDir;
+ }
+ }
+
+ expect(runtimeStateDir).not.toBe(operatorStateDir);
+ await expect(fs.realpath(discoveredRoot)).resolves.toBe(await fs.realpath(pluginDir));
+ await expect(fs.stat(runtimeStateDir)).rejects.toMatchObject({ code: "ENOENT" });
+ });
+
+ it("keeps operator-installed plugins hidden under --isolated", async () => {
+ const operatorStateDir = await makeTempRoot("openclaw-agent-exec-plugin-isolated-");
+ const previousStateDir = process.env.OPENCLAW_STATE_DIR;
+ process.env.OPENCLAW_STATE_DIR = operatorStateDir;
+ const { runtime } = createRuntime();
+ let resolvedExtensionsDir = "";
+ try {
+ await agentExecCommand("inspect", { isolated: true }, runtime, {
+ runAgent: vi.fn(async () => {
+ const { resolveDefaultPluginExtensionsDir } = await import("../plugins/install-paths.js");
+ resolvedExtensionsDir = resolveDefaultPluginExtensionsDir();
+ return successResult();
+ }),
+ });
+ } finally {
+ if (previousStateDir === undefined) {
+ delete process.env.OPENCLAW_STATE_DIR;
+ } else {
+ process.env.OPENCLAW_STATE_DIR = previousStateDir;
+ }
+ }
+
+ expect(resolvedExtensionsDir).not.toBe(path.join(operatorStateDir, "extensions"));
+ expect(path.basename(path.dirname(resolvedExtensionsDir))).toMatch(/^openclaw-agent-exec-/u);
+ });
+
+ it("keeps --state-dir scoped to run state instead of plugin installs", async () => {
+ const operatorStateDir = await makeTempRoot("openclaw-agent-exec-plugin-operator-");
+ const retainedRunStateDir = await makeTempRoot("openclaw-agent-exec-retained-state-");
+ const previousStateDir = process.env.OPENCLAW_STATE_DIR;
+ process.env.OPENCLAW_STATE_DIR = operatorStateDir;
+ const { runtime } = createRuntime();
+ let resolvedExtensionsDir = "";
+ try {
+ await agentExecCommand("inspect", { stateDir: retainedRunStateDir }, runtime, {
+ runAgent: vi.fn(async () => {
+ expect(process.env.OPENCLAW_STATE_DIR).toBe(retainedRunStateDir);
+ const { resolveDefaultPluginExtensionsDir } = await import("../plugins/install-paths.js");
+ resolvedExtensionsDir = resolveDefaultPluginExtensionsDir();
+ return successResult();
+ }),
+ });
+ } finally {
+ if (previousStateDir === undefined) {
+ delete process.env.OPENCLAW_STATE_DIR;
+ } else {
+ process.env.OPENCLAW_STATE_DIR = previousStateDir;
+ }
+ }
+
+ expect(resolvedExtensionsDir).toBe(path.join(operatorStateDir, "extensions"));
+ await expect(fs.stat(retainedRunStateDir)).resolves.toBeDefined();
+ });
+
it("applies explicit Code Mode and lean local-model controls to the isolated config", async () => {
const { runtime } = createRuntime();
let observedConfig: unknown;
diff --git a/src/commands/agent-exec.ts b/src/commands/agent-exec.ts
index 353fe814d5ba..328ebe6b1d2a 100644
--- a/src/commands/agent-exec.ts
+++ b/src/commands/agent-exec.ts
@@ -594,6 +594,14 @@ export async function agentExecCommand(
after: envAfterConfigLoad,
});
const runConfig = buildExecRunConfig({ base: baseConfig, cwd, opts });
+ // Installed plugins belong to the operator config resolved above, not to
+ // the disposable state root used for this run. Capture all roots before
+ // OPENCLAW_STATE_DIR moves so discovery and the installed-index DB agree.
+ const inheritInstalledPlugins = opts.isolated !== true && opts.authEnvOnly !== true;
+ const pluginInstallContext = inheritInstalledPlugins
+ ? await import("../plugins/install-root-context.js")
+ : undefined;
+ const pluginInstallRoots = pluginInstallContext?.resolvePluginInstallRoots();
const timeout = normalizeTimeoutSeconds(opts.timeout);
const fallbacks = normalizeFallbacks(opts.model, opts.fallback);
const { resolveDefaultAgentDir } = await import("../agents/agent-scope-config.js");
@@ -661,10 +669,14 @@ export async function agentExecCommand(
// Stored credentials are the default so a folder-scoped run reaches the
// same logins as the rest of the CLI; `--auth-env-only` opts back into an
// environment-only scope for automation.
+ const runWithPluginInstallRoots = () =>
+ pluginInstallContext && pluginInstallRoots
+ ? pluginInstallContext.withPluginInstallRoots(pluginInstallRoots, invoke)
+ : invoke();
const runWithAuthScope = () =>
opts.authEnvOnly === true
- ? withEnvOnlyAuthProfileStore(invoke)
- : withAuthProfileStoreAgentDir(storedAuthAgentDir, invoke);
+ ? withEnvOnlyAuthProfileStore(runWithPluginInstallRoots)
+ : withAuthProfileStoreAgentDir(storedAuthAgentDir, runWithPluginInstallRoots);
const result = await withHostExecInheritedEnvOmitted(
listKnownProviderAuthEnvVarNames({ env: process.env }),
runWithAuthScope,
diff --git a/src/plugins/install-paths.test.ts b/src/plugins/install-paths.test.ts
index dfe3e67157cb..1f0266e8ee86 100644
--- a/src/plugins/install-paths.test.ts
+++ b/src/plugins/install-paths.test.ts
@@ -2,9 +2,54 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
+ resolveDefaultPluginExtensionsDir,
+ resolveDefaultPluginGitDir,
+ resolveDefaultPluginNpmDir,
resolvePluginNpmGenerationProjectDir,
resolvePluginNpmGenerationProjectDirPrefix,
} from "./install-paths.js";
+import { resolvePluginInstallRoots, withPluginInstallRoots } from "./install-root-context.js";
+import { resolveInstalledPluginIndexStorePath } from "./installed-plugin-index-store-path.js";
+
+describe("plugin install root context", () => {
+ it("keeps discovery roots on the operator install while runtime state is redirected", async () => {
+ const operatorRoots = resolvePluginInstallRoots(
+ { OPENCLAW_STATE_DIR: "/operator/openclaw" },
+ () => "/unused-home",
+ );
+ const redirectedEnv = { OPENCLAW_STATE_DIR: "/tmp/ephemeral-run" };
+
+ await withPluginInstallRoots(operatorRoots, async () => {
+ await Promise.resolve();
+ expect(resolveDefaultPluginExtensionsDir(redirectedEnv)).toBe(
+ "/operator/openclaw/extensions",
+ );
+ expect(resolveDefaultPluginNpmDir(redirectedEnv)).toBe("/operator/openclaw/npm");
+ expect(resolveDefaultPluginGitDir(redirectedEnv)).toBe("/operator/openclaw/git");
+ expect(resolveInstalledPluginIndexStorePath({ env: redirectedEnv })).toBe(
+ "/operator/openclaw/state/openclaw.sqlite",
+ );
+ });
+
+ expect(resolveDefaultPluginExtensionsDir(redirectedEnv)).toBe("/tmp/ephemeral-run/extensions");
+ });
+
+ it("isolates concurrent install-root scopes", async () => {
+ const resolveScopedRoot = async (stateDir: string) => {
+ const roots = resolvePluginInstallRoots({ OPENCLAW_STATE_DIR: stateDir });
+ return await withPluginInstallRoots(roots, async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 0);
+ });
+ return resolveDefaultPluginExtensionsDir({ OPENCLAW_STATE_DIR: "/redirected" });
+ });
+ };
+
+ await expect(
+ Promise.all([resolveScopedRoot("/operator/one"), resolveScopedRoot("/operator/two")]),
+ ).resolves.toEqual(["/operator/one/extensions", "/operator/two/extensions"]);
+ });
+});
describe("managed npm plugin install paths", () => {
it("keeps generation project names compact for nested Windows runtime binaries", () => {
diff --git a/src/plugins/install-paths.ts b/src/plugins/install-paths.ts
index 6ecf2eee8034..2af9c0aef4f6 100644
--- a/src/plugins/install-paths.ts
+++ b/src/plugins/install-paths.ts
@@ -7,7 +7,8 @@ import {
safePathSegmentHashed,
unscopedPackageName,
} from "../infra/install-safe-path.js";
-import { resolveConfigDir, resolveUserPath } from "../utils.js";
+import { resolveUserPath } from "../utils.js";
+import { resolveActivePluginInstallRoots } from "./install-root-context.js";
/** Encodes arbitrary input as a safe plugin install filename. */
export function safePluginInstallFileName(input: string): string {
@@ -84,7 +85,7 @@ export function resolveDefaultPluginExtensionsDir(
env: NodeJS.ProcessEnv = process.env,
homedir?: () => string,
): string {
- return path.join(resolveConfigDir(env, homedir), "extensions");
+ return resolveActivePluginInstallRoots(env, homedir).extensionsDir;
}
/** Resolves the default directory for managed npm plugin installs. */
@@ -92,7 +93,7 @@ export function resolveDefaultPluginNpmDir(
env: NodeJS.ProcessEnv = process.env,
homedir?: () => string,
): string {
- return path.join(resolveConfigDir(env, homedir), "npm");
+ return resolveActivePluginInstallRoots(env, homedir).npmDir;
}
/** Encodes an npm package name into a managed npm project directory name. */
@@ -166,7 +167,7 @@ export function resolveDefaultPluginGitDir(
env: NodeJS.ProcessEnv = process.env,
homedir?: () => string,
): string {
- return path.join(resolveConfigDir(env, homedir), "git");
+ return resolveActivePluginInstallRoots(env, homedir).gitDir;
}
/** Resolves the safe install directory for one plugin id. */
diff --git a/src/plugins/install-root-context.ts b/src/plugins/install-root-context.ts
new file mode 100644
index 000000000000..0adcf88577c1
--- /dev/null
+++ b/src/plugins/install-root-context.ts
@@ -0,0 +1,51 @@
+import { AsyncLocalStorage } from "node:async_hooks";
+import os from "node:os";
+import path from "node:path";
+import { resolveStateDir } from "../config/paths.js";
+import { resolveGlobalSingleton } from "../shared/global-singleton.js";
+import { resolveConfigDir } from "../utils.js";
+
+const PLUGIN_INSTALL_ROOT_CONTEXT_KEY = Symbol.for("openclaw.pluginInstallRootContext");
+
+/** Immutable roots that own installed plugin artifacts and their registry. */
+export type PluginInstallRoots = Readonly<{
+ extensionsDir: string;
+ gitDir: string;
+ npmDir: string;
+ stateDir: string;
+}>;
+
+const pluginInstallRootContext = resolveGlobalSingleton>(
+ PLUGIN_INSTALL_ROOT_CONTEXT_KEY,
+ () => new AsyncLocalStorage(),
+);
+
+/** Resolve the ordinary operator-owned plugin roots before a run redirects state. */
+export function resolvePluginInstallRoots(
+ env: NodeJS.ProcessEnv = process.env,
+ homedir: () => string = os.homedir,
+): PluginInstallRoots {
+ const configDir = resolveConfigDir(env, homedir);
+ return Object.freeze({
+ extensionsDir: path.join(configDir, "extensions"),
+ gitDir: path.join(configDir, "git"),
+ npmDir: path.join(configDir, "npm"),
+ stateDir: resolveStateDir(env, homedir),
+ });
+}
+
+/** Return run-pinned install roots, or resolve the caller's ordinary roots. */
+export function resolveActivePluginInstallRoots(
+ env: NodeJS.ProcessEnv = process.env,
+ homedir: () => string = os.homedir,
+): PluginInstallRoots {
+ return pluginInstallRootContext.getStore() ?? resolvePluginInstallRoots(env, homedir);
+}
+
+/**
+ * Keep plugin discovery on one operator-owned install generation while a run
+ * redirects OPENCLAW_STATE_DIR for ephemeral sessions and runtime state.
+ */
+export function withPluginInstallRoots(roots: PluginInstallRoots, run: () => T): T {
+ return pluginInstallRootContext.run(roots, run);
+}
diff --git a/src/plugins/installed-plugin-index-store-path.ts b/src/plugins/installed-plugin-index-store-path.ts
index bfb3373e88ba..99ba8bce7f4c 100644
--- a/src/plugins/installed-plugin-index-store-path.ts
+++ b/src/plugins/installed-plugin-index-store-path.ts
@@ -1,8 +1,8 @@
// Resolves filesystem paths for installed plugin index storage.
import path from "node:path";
-import { resolveStateDir } from "../config/paths.js";
import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js";
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
+import { resolveActivePluginInstallRoots } from "./install-root-context.js";
const LEGACY_INSTALLED_PLUGIN_INDEX_STORE_PATH = path.join("plugins", "installs.json");
@@ -14,9 +14,9 @@ export type InstalledPluginIndexStoreOptions = {
};
function resolveStoreEnv(options: InstalledPluginIndexStoreOptions): NodeJS.ProcessEnv {
- return options.stateDir
- ? { ...(options.env ?? process.env), OPENCLAW_STATE_DIR: options.stateDir }
- : (options.env ?? process.env);
+ const env = options.env ?? process.env;
+ const stateDir = options.stateDir ?? resolveActivePluginInstallRoots(env).stateDir;
+ return { ...env, OPENCLAW_STATE_DIR: stateDir };
}
/** Resolves the canonical SQLite-backed installed plugin index path. */
@@ -55,6 +55,6 @@ export function resolveLegacyInstalledPluginIndexStorePath(
return options.filePath;
}
const env = options.env ?? process.env;
- const stateDir = options.stateDir ?? resolveStateDir(env);
+ const stateDir = options.stateDir ?? resolveActivePluginInstallRoots(env).stateDir;
return path.join(stateDir, LEGACY_INSTALLED_PLUGIN_INDEX_STORE_PATH);
}
diff --git a/src/plugins/manifest-metadata-scan.ts b/src/plugins/manifest-metadata-scan.ts
index d3cf430095cd..920bf374520f 100644
--- a/src/plugins/manifest-metadata-scan.ts
+++ b/src/plugins/manifest-metadata-scan.ts
@@ -3,13 +3,13 @@ import fs from "node:fs";
import path from "node:path";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString as normalizeTrimmedString } from "@openclaw/normalization-core/string-coerce";
-import { resolveStateDir } from "../config/paths.js";
import { resolveHomeRelativePath } from "../infra/home-dir.js";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import { readRegularFileSync } from "../infra/regular-file.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js";
import { resolveBundledPluginsDir } from "./bundled-dir.js";
+import { resolveDefaultPluginExtensionsDir } from "./install-paths.js";
import { readPersistedInstalledPluginIndexSync } from "./installed-plugin-index-store.js";
// Plugin manifest files are small metadata descriptors. Bound reads to prevent
@@ -187,7 +187,7 @@ export function listOpenClawPluginManifestMetadata(
candidates.push(...listSourceCheckoutPluginDirs(order));
order = candidates.length;
candidates.push(
- ...listChildPluginDirs(path.join(resolveStateDir(env), "extensions"), 4, order, "global"),
+ ...listChildPluginDirs(resolveDefaultPluginExtensionsDir(env), 4, order, "global"),
);
const uniqueCandidates = uniqueCandidateDirs(candidates);
diff --git a/src/plugins/plugin-metadata-snapshot.ts b/src/plugins/plugin-metadata-snapshot.ts
index d7f8f0602b62..dd06eb2c3485 100644
--- a/src/plugins/plugin-metadata-snapshot.ts
+++ b/src/plugins/plugin-metadata-snapshot.ts
@@ -5,6 +5,7 @@ import {
measureDiagnosticsTimelineSpanSync,
} from "../infra/diagnostics-timeline.js";
import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
+import { resolveActivePluginInstallRoots } from "./install-root-context.js";
import { hashJson } from "./installed-plugin-index-hash.js";
import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js";
import {
@@ -63,7 +64,10 @@ function pickPluginMetadataEnv(env: NodeJS.ProcessEnv): Record {
}
export function resolvePluginMetadataEnvFingerprint(env: NodeJS.ProcessEnv): string {
- return hashJson(pickPluginMetadataEnv(env));
+ return hashJson({
+ env: pickPluginMetadataEnv(env),
+ installRoots: resolveActivePluginInstallRoots(env),
+ });
}
function throwReadonlyPluginMetadataMutation(): never {
diff --git a/src/plugins/plugin-registry-snapshot.test.ts b/src/plugins/plugin-registry-snapshot.test.ts
index 07ee5bd28c75..9efae4b08fe5 100644
--- a/src/plugins/plugin-registry-snapshot.test.ts
+++ b/src/plugins/plugin-registry-snapshot.test.ts
@@ -742,7 +742,9 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
writePackagePlugin(firstRoot, { pluginId: "duplicate" });
writePackagePlugin(secondRoot, { pluginId: "duplicate" });
const originalIndex = loadInstalledPluginIndex({ config: originalConfig, env });
- expect(originalIndex.plugins.map((plugin) => plugin.rootDir)).toEqual([firstRoot]);
+ expect(originalIndex.plugins.map((plugin) => plugin.rootDir)).toEqual([
+ fs.realpathSync(firstRoot),
+ ]);
writePersistedInstalledPluginIndexSync(originalIndex, { stateDir });
const unchanged = loadPluginRegistrySnapshotWithMetadata({
@@ -759,7 +761,9 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
});
expect(reordered.source).toBe("derived");
expectDiagnosticsContainCode(reordered.diagnostics, "persisted-registry-stale-source");
- expect(reordered.snapshot.plugins.map((plugin) => plugin.rootDir)).toEqual([secondRoot]);
+ expect(reordered.snapshot.plugins.map((plugin) => plugin.rootDir)).toEqual([
+ fs.realpathSync(secondRoot),
+ ]);
});
it("rebuilds legacy config-path persisted registries before startup scoping", () => {
diff --git a/src/plugins/plugin-registry-snapshot.ts b/src/plugins/plugin-registry-snapshot.ts
index 876cd1ddded3..f72584da44a5 100644
--- a/src/plugins/plugin-registry-snapshot.ts
+++ b/src/plugins/plugin-registry-snapshot.ts
@@ -12,6 +12,7 @@ import { normalizePluginsConfig } from "./config-state.js";
import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
import { clearCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-state.js";
import { discoverConfiguredPluginLoadPaths, type PluginDiscoveryResult } from "./discovery.js";
+import { resolveActivePluginInstallRoots } from "./install-root-context.js";
import { fileSignatureMatches, hashJson } from "./installed-plugin-index-hash.js";
import { hasOptionalMissingPluginManifestFile } from "./installed-plugin-index-manifest.js";
import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js";
@@ -135,6 +136,7 @@ function resolvePluginRegistrySnapshotMemoKey(
config: params.config ?? null,
cwd: process.cwd(),
env: pickRegistrySnapshotMemoEnv(env),
+ installRoots: resolveActivePluginInstallRoots(env),
hostContractVersion: resolveCompatibilityHostVersion(env),
preferPersisted: params.preferPersisted ?? null,
// Install, reload, and persisted-index writes clear this memo explicitly.
diff --git a/src/plugins/roots.ts b/src/plugins/roots.ts
index 0032331d5de1..2c160de34d6a 100644
--- a/src/plugins/roots.ts
+++ b/src/plugins/roots.ts
@@ -1,8 +1,9 @@
// Resolves plugin root directories for bundled and installed plugins.
import path from "node:path";
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
-import { resolveConfigDir, resolveUserPath } from "../utils.js";
+import { resolveUserPath } from "../utils.js";
import { resolveBundledPluginsDir } from "./bundled-dir.js";
+import { resolveDefaultPluginExtensionsDir } from "./install-paths.js";
export type PluginSourceRoots = {
stock?: string;
@@ -22,7 +23,7 @@ export function resolvePluginSourceRoots(params: {
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 global = resolveDefaultPluginExtensionsDir(env);
const workspace = workspaceRoot ? path.join(workspaceRoot, ".openclaw", "extensions") : undefined;
return { stock, global, workspace };
}