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 }; }