Files
openclaw/src/plugins/plugin-metadata-snapshot.memo.test.ts
WhatsSkiLL 22b8e1cf4f fix(plugins): scope startup metadata manifest reads
Limit plugin metadata snapshots to the channel, provider, and startup surfaces that need them, while preserving unscoped fallback for incomplete index data and provider runtime resolution.

Refs #70533.
Refs #84628.

Co-authored-by: IWhatsskill <IWhatsskill@users.noreply.github.com>
2026-05-31 11:58:56 +01:00

907 lines
32 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
clearCurrentPluginMetadataSnapshot,
setCurrentPluginMetadataSnapshot,
} from "./current-plugin-metadata-snapshot.js";
import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js";
import type { InstalledPluginIndex } from "./installed-plugin-index.js";
import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js";
import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js";
import {
clearLoadPluginMetadataSnapshotMemo,
loadPluginMetadataSnapshot,
resolvePluginMetadataSnapshot,
} from "./plugin-metadata-snapshot.js";
const loadPluginRegistrySnapshotWithMetadata = vi.hoisted(() => vi.fn());
const loadPluginManifestRegistryForInstalledIndex = vi.hoisted(() => vi.fn());
vi.mock("./plugin-registry.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./plugin-registry.js")>();
return {
...actual,
loadPluginRegistrySnapshotWithMetadata: (params: unknown) =>
loadPluginRegistrySnapshotWithMetadata(params),
};
});
vi.mock("./manifest-registry-installed.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./manifest-registry-installed.js")>();
return {
...actual,
loadPluginManifestRegistryForInstalledIndex: (params: unknown) =>
loadPluginManifestRegistryForInstalledIndex(params),
};
});
const tempDirs: string[] = [];
function tempStateDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-metadata-memo-"));
tempDirs.push(dir);
return dir;
}
function touchPersistedIndex(stateDir: string, value = 1): void {
const indexPath = path.join(stateDir, "plugins", "installs.json");
fs.mkdirSync(path.dirname(indexPath), { recursive: true });
fs.writeFileSync(indexPath, JSON.stringify({ value }));
}
function writeJson(filePath: string, value: unknown): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`);
}
function writePersistedIndex(params: {
manifestPath?: string;
packageJsonPath?: string;
pluginId: string;
source?: string;
setupSource?: string;
stateDir: string;
}): void {
const pluginDir = path.join(params.stateDir, "extensions", params.pluginId);
const manifestPath = params.manifestPath ?? path.join(pluginDir, "openclaw.plugin.json");
const packageJsonPath = params.packageJsonPath ?? path.join(pluginDir, "package.json");
writeJson(path.join(params.stateDir, "plugins", "installs.json"), {
version: 1,
hostContractVersion: "test",
compatRegistryVersion: "test",
migrationVersion: 1,
policyHash: "test",
generatedAtMs: 1,
installRecords: {},
diagnostics: [],
plugins: [
{
pluginId: params.pluginId,
manifestPath,
manifestHash: `${params.pluginId}-manifest`,
rootDir: pluginDir,
...(params.source ? { source: params.source } : {}),
...(params.setupSource ? { setupSource: params.setupSource } : {}),
origin: "global",
enabled: true,
packageJson: { path: "package.json", hash: `${params.pluginId}-package` },
startup: {
sidecar: false,
memory: false,
deferConfiguredChannelFullLoadUntilAfterListen: false,
agentHarnesses: [],
},
compat: [],
},
],
});
writeJson(manifestPath, { id: params.pluginId });
writeJson(packageJsonPath, { name: params.pluginId });
}
function writeRecoverableNpmPlugin(params: {
packageName: string;
pluginId: string;
stateDir: string;
version: string;
writeRootManifest?: boolean;
}): void {
const packageDir = path.join(params.stateDir, "npm", "node_modules", params.packageName);
if (params.writeRootManifest !== false) {
writeJson(path.join(params.stateDir, "npm", "package.json"), {
dependencies: {
[params.packageName]: "1.0.0",
},
});
}
writeJson(path.join(packageDir, "package.json"), {
name: params.packageName,
version: params.version,
openclaw: {
extensions: ["."],
},
});
writeJson(path.join(packageDir, "openclaw.plugin.json"), { id: params.pluginId });
}
function writePersistedInstallRecords(
stateDir: string,
installRecords: Record<string, Record<string, unknown>>,
): void {
writeJson(path.join(stateDir, "plugins", "installs.json"), {
version: 1,
hostContractVersion: "test",
compatRegistryVersion: "test",
migrationVersion: 1,
policyHash: "test",
generatedAtMs: 1,
installRecords,
diagnostics: [],
plugins: [],
});
}
function makeIndex(
pluginId = "demo",
options: {
manifestPath?: string;
rootDir?: string;
} = {},
): InstalledPluginIndex {
const rootDir = options.rootDir ?? `/plugins/${pluginId}`;
const manifestPath = options.manifestPath ?? path.join(rootDir, "openclaw.plugin.json");
return {
version: 1,
hostContractVersion: "test",
compatRegistryVersion: "test",
migrationVersion: 1,
policyHash: "test",
generatedAtMs: 1,
installRecords: {},
diagnostics: [],
plugins: [
{
pluginId,
manifestPath,
manifestHash: `${pluginId}-manifest`,
rootDir,
origin: "global",
enabled: true,
startup: {
sidecar: false,
memory: false,
deferConfiguredChannelFullLoadUntilAfterListen: false,
agentHarnesses: [],
},
compat: [],
},
],
};
}
function makeManifestRegistry(pluginId = "demo"): PluginManifestRegistry {
const plugin: PluginManifestRecord = {
id: pluginId,
name: pluginId,
channels: [],
providers: [pluginId],
cliBackends: [],
skills: [],
hooks: [],
commandAliases: [{ name: `${pluginId}-command` }],
rootDir: `/plugins/${pluginId}`,
source: `/plugins/${pluginId}/index.js`,
manifestPath: `/plugins/${pluginId}/openclaw.plugin.json`,
origin: "global",
};
return { plugins: [plugin], diagnostics: [] };
}
describe("loadPluginMetadataSnapshot process memo", () => {
beforeEach(() => {
clearLoadPluginMetadataSnapshotMemo();
loadPluginRegistrySnapshotWithMetadata.mockReset();
loadPluginManifestRegistryForInstalledIndex.mockReset();
loadPluginManifestRegistryForInstalledIndex.mockReturnValue(makeManifestRegistry());
});
afterEach(() => {
clearLoadPluginMetadataSnapshotMemo();
clearCurrentPluginMetadataSnapshot();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("reuses persisted metadata snapshots for repeated process lookups", () => {
const stateDir = tempStateDir();
touchPersistedIndex(stateDir);
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "persisted",
snapshot: makeIndex(),
diagnostics: [],
});
const first = loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
const second = loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
const third = loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce();
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce();
expect(second).toBe(first);
expect(third).toBe(first);
expect(() => third.plugins[0]?.providers.push("mutated")).toThrow();
expect(() => {
if (third.plugins[0]?.commandAliases?.[0]) {
third.plugins[0].commandAliases[0].name = "mutated";
}
}).toThrow();
expect(third.plugins[0]?.providers).toEqual(["demo"]);
expect(third.plugins[0]?.commandAliases?.[0]?.name).toBe("demo-command");
expect(second.manifestRegistry.plugins[0]).toBe(second.plugins[0]);
expect(second.byPluginId.get("demo")).toBe(second.plugins[0]);
});
it("skips persisted registry filesystem fingerprints after a process memo hit", () => {
const stateDir = tempStateDir();
touchPersistedIndex(stateDir);
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "persisted",
snapshot: makeIndex(),
diagnostics: [],
});
const first = loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
const statSpy = vi.spyOn(fs, "statSync");
const readdirSpy = vi.spyOn(fs, "readdirSync");
try {
const second = loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
expect(second).toBe(first);
expect(statSpy).not.toHaveBeenCalled();
expect(readdirSpy).not.toHaveBeenCalled();
} finally {
statSpy.mockRestore();
readdirSpy.mockRestore();
}
});
it("clears the process memo at plugin metadata lifecycle boundaries", () => {
const stateDir = tempStateDir();
touchPersistedIndex(stateDir);
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "persisted",
snapshot: makeIndex(),
diagnostics: [],
});
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
clearPluginMetadataLifecycleCaches();
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledTimes(2);
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledTimes(2);
});
it("keeps scoped and unscoped metadata snapshots in separate memo slots", () => {
const index = makeIndex();
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "provided",
snapshot: index,
diagnostics: [],
});
const scoped = loadPluginMetadataSnapshot({
config: {},
env: {},
index,
pluginIds: ["demo"],
});
const unscoped = loadPluginMetadataSnapshot({
config: {},
env: {},
index,
});
expect(scoped.pluginIds).toEqual(["demo"]);
expect(unscoped.pluginIds).toBeUndefined();
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledTimes(2);
expect(loadPluginManifestRegistryForInstalledIndex.mock.calls[0]?.[0]).toMatchObject({
pluginIds: ["demo"],
});
expect(loadPluginManifestRegistryForInstalledIndex.mock.calls[1]?.[0]).not.toHaveProperty(
"pluginIds",
);
});
it("keeps hot persisted snapshots for alternating config callers", () => {
const stateDir = tempStateDir();
touchPersistedIndex(stateDir);
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "persisted",
snapshot: makeIndex(),
diagnostics: [],
});
loadPluginMetadataSnapshot({
config: { plugins: { allow: ["demo"] } },
env: {},
stateDir,
});
loadPluginMetadataSnapshot({
config: { plugins: { allow: ["other"] } },
env: {},
stateDir,
});
loadPluginMetadataSnapshot({
config: { plugins: { allow: ["demo"] } },
env: {},
stateDir,
});
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledTimes(2);
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledTimes(2);
});
it("reuses workspace-scoped current snapshots when the caller opts in", () => {
const index = makeIndex();
index.policyHash = resolveInstalledPluginIndexPolicyHash({});
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "runtime",
snapshot: index,
diagnostics: [],
});
const snapshot = loadPluginMetadataSnapshot({
config: {},
env: {},
index,
workspaceDir: "/workspace/a",
});
setCurrentPluginMetadataSnapshot(snapshot, {
config: {},
env: {},
workspaceDir: "/workspace/a",
});
loadPluginRegistrySnapshotWithMetadata.mockClear();
loadPluginManifestRegistryForInstalledIndex.mockClear();
expect(
resolvePluginMetadataSnapshot({
config: {},
env: {},
allowWorkspaceScopedCurrent: true,
}),
).toBe(snapshot);
expect(loadPluginRegistrySnapshotWithMetadata).not.toHaveBeenCalled();
expect(loadPluginManifestRegistryForInstalledIndex).not.toHaveBeenCalled();
});
it("reuses compatible current snapshots without reloading metadata", () => {
const sourceConfig = { plugins: { allow: ["demo"] } };
const compatibleConfig = { plugins: { entries: { demo: { enabled: true } } } };
const index = makeIndex();
index.policyHash = resolveInstalledPluginIndexPolicyHash(sourceConfig);
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "runtime",
snapshot: index,
diagnostics: [],
});
const snapshot = loadPluginMetadataSnapshot({
config: sourceConfig,
env: {},
index,
workspaceDir: "/workspace/a",
});
setCurrentPluginMetadataSnapshot(snapshot, {
config: sourceConfig,
compatibleConfigs: [compatibleConfig],
env: {},
workspaceDir: "/workspace/a",
});
loadPluginRegistrySnapshotWithMetadata.mockClear();
loadPluginManifestRegistryForInstalledIndex.mockClear();
expect(
resolvePluginMetadataSnapshot({
config: compatibleConfig,
env: {},
workspaceDir: "/workspace/a",
}),
).toBe(snapshot);
expect(loadPluginRegistrySnapshotWithMetadata).not.toHaveBeenCalled();
expect(loadPluginManifestRegistryForInstalledIndex).not.toHaveBeenCalled();
});
it("does not reuse an unscoped current snapshot for a scoped resolver request", () => {
const index = makeIndex("demo");
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "runtime",
snapshot: index,
diagnostics: [],
});
loadPluginManifestRegistryForInstalledIndex.mockImplementation(
({ pluginIds }: { pluginIds?: readonly string[] }) => ({
...makeManifestRegistry(pluginIds?.[0] ?? "demo"),
plugins: pluginIds?.map((pluginId) => makeManifestRegistry(pluginId).plugins[0]) ?? [
makeManifestRegistry("demo").plugins[0],
],
}),
);
const unscoped = loadPluginMetadataSnapshot({
config: {},
env: {},
index,
});
setCurrentPluginMetadataSnapshot(unscoped, {
config: {},
env: {},
});
loadPluginManifestRegistryForInstalledIndex.mockClear();
const scoped = resolvePluginMetadataSnapshot({
config: {},
env: {},
index,
pluginIdScope: {
key: "demo-only",
resolve: () => ["demo"],
},
});
expect(scoped).not.toBe(unscoped);
expect(scoped.pluginIds).toEqual(["demo"]);
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledWith(
expect.objectContaining({ pluginIds: ["demo"] }),
);
});
it("does not scan persisted registry files when the caller provides an index", () => {
const stateDir = tempStateDir();
writePersistedIndex({ pluginId: "demo", stateDir });
const index = makeIndex();
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "provided",
snapshot: index,
diagnostics: [],
});
loadPluginMetadataSnapshot({ config: {}, env: {}, index, stateDir });
const statSpy = vi.spyOn(fs, "statSync");
const readSpy = vi.spyOn(fs, "readFileSync");
try {
loadPluginMetadataSnapshot({ config: {}, env: {}, index, stateDir });
} finally {
statSpy.mockRestore();
readSpy.mockRestore();
}
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce();
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce();
expect(statSpy).not.toHaveBeenCalled();
expect(readSpy).not.toHaveBeenCalled();
});
it("does not freeze caller-owned provided index records", () => {
const stateDir = tempStateDir();
const index = makeIndex();
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "provided",
snapshot: index,
diagnostics: [],
});
const snapshot = loadPluginMetadataSnapshot({ config: {}, env: {}, index, stateDir });
const callerRecord = index.plugins[0];
const snapshotRecord = snapshot.index.plugins[0];
if (!callerRecord || !snapshotRecord) {
throw new Error("expected metadata records");
}
expect(() => {
callerRecord.pluginId = "caller-mutated";
callerRecord.startup.agentHarnesses = ["caller-mutated"];
}).not.toThrow();
expect(snapshot.index.plugins[0]?.pluginId).toBe("demo");
expect(snapshot.index.plugins[0]?.startup.agentHarnesses).toEqual([]);
expect(() => {
snapshotRecord.pluginId = "snapshot-mutated";
}).toThrow();
});
it("memoizes policy-stale derived snapshots within the process", () => {
const stateDir = tempStateDir();
touchPersistedIndex(stateDir);
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "derived",
snapshot: makeIndex(),
diagnostics: [
{
level: "warn",
code: "persisted-registry-stale-policy",
message: "policy changed",
},
],
});
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce();
});
it("keeps process-stable derived snapshots when derived plugin files change", () => {
const stateDir = tempStateDir();
touchPersistedIndex(stateDir);
const pluginDir = path.join(stateDir, "current", "derived");
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
writeJson(manifestPath, { id: "derived", version: "1.0.0" });
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "derived",
snapshot: makeIndex("derived", { manifestPath, rootDir: pluginDir }),
diagnostics: [
{
level: "warn",
code: "persisted-registry-stale-policy",
message: "policy changed",
},
],
});
loadPluginManifestRegistryForInstalledIndex.mockReturnValue(makeManifestRegistry("derived"));
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
writeJson(manifestPath, { id: "derived", version: "2.0.0", commandAliases: [{ name: "new" }] });
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce();
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce();
});
it.each([
["persisted-registry-missing", undefined],
["persisted-registry-stale-source", undefined],
["persisted-registry-disabled", undefined],
[undefined, { preferPersisted: false }],
])("memoizes derived snapshots for %s diagnostics within the process", (code, options) => {
const stateDir = tempStateDir();
touchPersistedIndex(stateDir);
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "derived",
snapshot: makeIndex(),
diagnostics: code ? [{ level: "warn", code, message: "registry not reusable" }] : [],
});
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir, ...options });
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir, ...options });
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce();
});
it("keeps persisted registry snapshots process-stable until lifecycle clear", () => {
const stateDir = tempStateDir();
touchPersistedIndex(stateDir, 1);
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "persisted",
snapshot: makeIndex(),
diagnostics: [],
});
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
touchPersistedIndex(stateDir, 22);
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce();
clearPluginMetadataLifecycleCaches();
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledTimes(2);
});
it("reuses the expanded freshness fingerprint on hot cache hits", () => {
const stateDir = tempStateDir();
const manifestPath = path.join(stateDir, "extensions", "demo", "openclaw.plugin.json");
writePersistedIndex({ manifestPath, pluginId: "demo", stateDir });
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "persisted",
snapshot: makeIndex(),
diagnostics: [],
});
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
const readSpy = vi.spyOn(fs, "readFileSync");
try {
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
} finally {
readSpy.mockRestore();
}
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledTimes(1);
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledTimes(1);
expect(readSpy).not.toHaveBeenCalled();
});
it.each([
["manifest", "openclaw.plugin.json", "manifestPath"],
["source", "index.js", "source"],
["setup source", "setup.js", "setupSource"],
["package manifest", "package.json", "packageJsonPath"],
])("requires reload before persisted plugin %s edits are visible", (_, fileName, field) => {
const stateDir = tempStateDir();
const filePath = path.join(stateDir, "extensions", "demo", fileName);
writePersistedIndex({ [field]: filePath, pluginId: "demo", stateDir });
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "persisted",
snapshot: makeIndex(),
diagnostics: [],
});
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
writeJson(filePath, { id: "demo", version: "0.2.0" });
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce();
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce();
});
it.each([
[
"install path package manifest",
"~/tracked-plugin",
(recordPath: string) => ({ source: "path", installPath: recordPath }),
(homeDir: string) => path.join(homeDir, "tracked-plugin", "package.json"),
],
[
"source path package manifest",
"~/tracked-plugin",
(recordPath: string) => ({ source: "path", sourcePath: recordPath }),
(homeDir: string) => path.join(homeDir, "tracked-plugin", "package.json"),
],
])(
"requires reload before home-relative install record %s changes are visible",
(_, recordPath, record, targetPath) => {
const stateDir = tempStateDir();
const homeDir = path.join(stateDir, "home");
const filePath = targetPath(homeDir);
writePersistedInstallRecords(stateDir, { demo: record(recordPath) });
writeJson(filePath, { version: "1.0.0" });
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "persisted",
snapshot: makeIndex(),
diagnostics: [],
});
loadPluginMetadataSnapshot({ config: {}, env: { HOME: homeDir }, stateDir });
writeJson(filePath, { version: "1.0.1000" });
loadPluginMetadataSnapshot({ config: {}, env: { HOME: homeDir }, stateDir });
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce();
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce();
},
);
it("does not reuse home-relative install record memo state across env changes", () => {
const stateDir = tempStateDir();
const firstHomeDir = path.join(stateDir, "first-home");
const secondHomeDir = path.join(stateDir, "second-home");
const firstPackageJsonPath = path.join(firstHomeDir, "tracked-plugin", "package.json");
const secondPackageJsonPath = path.join(secondHomeDir, "tracked-plugin", "package.json");
writePersistedInstallRecords(stateDir, {
demo: { source: "path", installPath: "~/tracked-plugin" },
});
writeJson(firstPackageJsonPath, { version: "1.0.0" });
writeJson(secondPackageJsonPath, { version: "1.0.0" });
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "persisted",
snapshot: makeIndex(),
diagnostics: [],
});
loadPluginMetadataSnapshot({ config: {}, env: { HOME: firstHomeDir }, stateDir });
loadPluginMetadataSnapshot({ config: {}, env: { HOME: secondHomeDir }, stateDir });
writeJson(secondPackageJsonPath, { version: "1.0.1000" });
loadPluginMetadataSnapshot({ config: {}, env: { HOME: secondHomeDir }, stateDir });
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledTimes(2);
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledTimes(2);
});
it("requires reload before recovered managed npm package metadata changes are visible", () => {
const stateDir = tempStateDir();
writeRecoverableNpmPlugin({
packageName: "recovered-plugin",
pluginId: "recovered",
stateDir,
version: "1.0.0",
});
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "persisted",
snapshot: makeIndex(),
diagnostics: [],
});
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
writeRecoverableNpmPlugin({
packageName: "recovered-plugin",
pluginId: "recovered",
stateDir,
version: "1.0.10",
writeRootManifest: false,
});
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce();
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce();
});
it("requires reload before a declared recovered managed npm package appears", () => {
const stateDir = tempStateDir();
writeJson(path.join(stateDir, "npm", "package.json"), {
dependencies: {
"late-plugin": "1.0.0",
},
});
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "persisted",
snapshot: makeIndex(),
diagnostics: [],
});
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
writeRecoverableNpmPlugin({
packageName: "late-plugin",
pluginId: "late-plugin",
stateDir,
version: "1.0.0",
writeRootManifest: false,
});
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce();
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce();
});
it("keeps npm project package fingerprints stable across directory order changes", () => {
const stateDir = tempStateDir();
const projectsDir = path.join(stateDir, "npm", "projects");
writeJson(path.join(projectsDir, "zeta", "package.json"), { name: "zeta", version: "1.0.0" });
writeJson(path.join(projectsDir, "alpha", "package.json"), { name: "alpha", version: "1.0.0" });
touchPersistedIndex(stateDir);
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "persisted",
snapshot: makeIndex(),
diagnostics: [],
});
const originalReaddirSync = fs.readdirSync.bind(fs);
let reverseProjectEntries = false;
const readdirSpy = vi.spyOn(fs, "readdirSync").mockImplementation(((
directoryPath: fs.PathLike,
options?: Parameters<typeof fs.readdirSync>[1],
): unknown => {
const entries = originalReaddirSync(directoryPath, options as never);
if (directoryPath === projectsDir && reverseProjectEntries && Array.isArray(entries)) {
return entries.toReversed();
}
return entries;
}) as unknown as typeof fs.readdirSync);
try {
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
reverseProjectEntries = true;
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
} finally {
readdirSpy.mockRestore();
}
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce();
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce();
});
it("requires reload before an in-root package manifest symlink target change is visible", () => {
const stateDir = tempStateDir();
const pluginDir = path.join(stateDir, "extensions", "demo");
const packageJsonPath = path.join(pluginDir, "package.json");
const outsidePackageJsonPath = path.join(stateDir, "outside", "package.json");
fs.mkdirSync(pluginDir, { recursive: true });
writeJson(outsidePackageJsonPath, { name: "outside", version: "1.0.0" });
fs.symlinkSync(outsidePackageJsonPath, packageJsonPath);
writePersistedIndex({ packageJsonPath, pluginId: "demo", stateDir });
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "persisted",
snapshot: makeIndex(),
diagnostics: [],
});
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
writeJson(outsidePackageJsonPath, { name: "outside", version: "1.0.1" });
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce();
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce();
});
it("does not fingerprint persisted plugin paths outside the plugin root", () => {
const stateDir = tempStateDir();
const outsideManifestPath = path.join(stateDir, "outside", "openclaw.plugin.json");
const outsideSourcePath = path.join(stateDir, "outside", "index.js");
writePersistedIndex({
manifestPath: outsideManifestPath,
pluginId: "demo",
source: outsideSourcePath,
stateDir,
});
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "persisted",
snapshot: makeIndex(),
diagnostics: [],
});
const statSpy = vi.spyOn(fs, "statSync");
const readSpy = vi.spyOn(fs, "readFileSync");
try {
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
} finally {
statSpy.mockRestore();
readSpy.mockRestore();
}
expect(statSpy.mock.calls.some(([filePath]) => filePath === outsideManifestPath)).toBe(false);
expect(statSpy.mock.calls.some(([filePath]) => filePath === outsideSourcePath)).toBe(false);
expect(readSpy.mock.calls.some(([filePath]) => filePath === outsideManifestPath)).toBe(false);
expect(readSpy.mock.calls.some(([filePath]) => filePath === outsideSourcePath)).toBe(false);
});
it("does not hash symlinked persisted plugin files that escape the plugin root", () => {
const stateDir = tempStateDir();
const pluginDir = path.join(stateDir, "extensions", "demo");
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
const outsideManifestPath = path.join(stateDir, "outside", "openclaw.plugin.json");
fs.mkdirSync(pluginDir, { recursive: true });
writeJson(outsideManifestPath, { id: "outside" });
fs.symlinkSync(outsideManifestPath, manifestPath);
writeJson(path.join(stateDir, "plugins", "installs.json"), {
version: 1,
hostContractVersion: "test",
compatRegistryVersion: "test",
migrationVersion: 1,
policyHash: "test",
generatedAtMs: 1,
installRecords: {},
diagnostics: [],
plugins: [
{
pluginId: "demo",
manifestPath,
manifestHash: "demo-manifest",
rootDir: pluginDir,
origin: "global",
enabled: true,
startup: {
sidecar: false,
memory: false,
deferConfiguredChannelFullLoadUntilAfterListen: false,
agentHarnesses: [],
},
compat: [],
},
],
});
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "persisted",
snapshot: makeIndex(),
diagnostics: [],
});
const readSpy = vi.spyOn(fs, "readFileSync");
try {
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
} finally {
readSpy.mockRestore();
}
expect(readSpy.mock.calls.some(([filePath]) => filePath === outsideManifestPath)).toBe(false);
});
});