fix(cli): preserve installed plugins in agent exec (#116336)

This commit is contained in:
Peter Steinberger
2026-07-30 03:00:17 -07:00
committed by GitHub
parent e52354ea13
commit a4c6efc998
14 changed files with 467 additions and 23 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ Config is layered in three parts, entirely in memory: exec composes the run conf
Use `--state-dir <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 <path>` 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.
+2 -4
View File
@@ -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) {
+214
View File
@@ -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<string> {
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<void> {
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<void> {
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" });
});
});
+112
View File
@@ -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;
+14 -2
View File
@@ -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,
+45
View File
@@ -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<void>((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", () => {
+5 -4
View File
@@ -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. */
+51
View File
@@ -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<AsyncLocalStorage<PluginInstallRoots>>(
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<T>(roots: PluginInstallRoots, run: () => T): T {
return pluginInstallRootContext.run(roots, run);
}
@@ -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);
}
+2 -2
View File
@@ -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);
+5 -1
View File
@@ -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<string, string> {
}
export function resolvePluginMetadataEnvFingerprint(env: NodeJS.ProcessEnv): string {
return hashJson(pickPluginMetadataEnv(env));
return hashJson({
env: pickPluginMetadataEnv(env),
installRoots: resolveActivePluginInstallRoots(env),
});
}
function throwReadonlyPluginMetadataMutation(): never {
+6 -2
View File
@@ -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", () => {
+2
View File
@@ -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.
+3 -2
View File
@@ -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 };
}