perf: cache startup package metadata

This commit is contained in:
Peter Steinberger
2026-05-02 16:11:00 +01:00
parent 5b063c2d83
commit ad0d87d881
15 changed files with 439 additions and 23 deletions

View File

@@ -19,6 +19,7 @@ type BundledChannelCatalogEntry = {
};
const OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH = path.join("dist", "channel-catalog.json");
const officialCatalogFileCache = new Map<string, ChannelCatalogEntryLike[] | null>();
function listPackageRoots(): string[] {
return [
@@ -38,15 +39,28 @@ function readBundledExtensionCatalogEntriesSync(): PluginPackageChannel[] {
function readOfficialCatalogFileSync(): ChannelCatalogEntryLike[] {
for (const packageRoot of listPackageRoots()) {
const candidate = path.join(packageRoot, OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH);
const cached = officialCatalogFileCache.get(candidate);
if (cached !== undefined) {
if (cached) {
return cached;
}
continue;
}
if (!fs.existsSync(candidate)) {
officialCatalogFileCache.set(candidate, null);
continue;
}
try {
const payload = JSON.parse(fs.readFileSync(candidate, "utf8")) as {
entries?: unknown;
};
return Array.isArray(payload.entries) ? (payload.entries as ChannelCatalogEntryLike[]) : [];
const entries = Array.isArray(payload.entries)
? (payload.entries as ChannelCatalogEntryLike[])
: [];
officialCatalogFileCache.set(candidate, entries);
return entries;
} catch {
officialCatalogFileCache.set(candidate, null);
continue;
}
}

View File

@@ -70,6 +70,7 @@ type ExternalCatalogEntry = {
const ENV_CATALOG_PATHS = ["OPENCLAW_PLUGIN_CATALOG_PATHS", "OPENCLAW_MPM_CATALOG_PATHS"];
const OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH = path.join("dist", "channel-catalog.json");
const officialCatalogEntriesByPath = new Map<string, ExternalCatalogEntry[] | null>();
type ManifestKey = typeof MANIFEST_KEY;
@@ -145,6 +146,32 @@ function loadCatalogEntriesFromPaths(paths: Iterable<string>): ExternalCatalogEn
return entries;
}
function loadOfficialCatalogEntriesFromPaths(paths: Iterable<string>): ExternalCatalogEntry[] {
const entries: ExternalCatalogEntry[] = [];
for (const resolvedPath of paths) {
const cached = officialCatalogEntriesByPath.get(resolvedPath);
if (cached !== undefined) {
if (cached) {
entries.push(...cached);
}
continue;
}
if (!fs.existsSync(resolvedPath)) {
officialCatalogEntriesByPath.set(resolvedPath, null);
continue;
}
try {
const payload = JSON.parse(fs.readFileSync(resolvedPath, "utf-8")) as unknown;
const parsed = parseCatalogEntries(payload);
officialCatalogEntriesByPath.set(resolvedPath, parsed);
entries.push(...parsed);
} catch {
officialCatalogEntriesByPath.set(resolvedPath, null);
}
}
return entries;
}
function resolveOfficialCatalogPaths(options: CatalogOptions): string[] {
if (options.officialCatalogPaths && options.officialCatalogPaths.length > 0) {
return options.officialCatalogPaths.map((entry) => entry.trim()).filter(Boolean);
@@ -170,7 +197,11 @@ function resolveOfficialCatalogPaths(options: CatalogOptions): string[] {
function loadOfficialCatalogEntries(options: CatalogOptions): ChannelPluginCatalogEntry[] {
const builtInEntries = parseCatalogEntries(officialExternalChannelCatalog);
const fileEntries = loadCatalogEntriesFromPaths(resolveOfficialCatalogPaths(options));
const officialPaths = resolveOfficialCatalogPaths(options);
const fileEntries =
options.officialCatalogPaths && options.officialCatalogPaths.length > 0
? loadCatalogEntriesFromPaths(officialPaths)
: loadOfficialCatalogEntriesFromPaths(officialPaths);
return [...builtInEntries, ...fileEntries]
.map((entry) => buildExternalCatalogEntry(entry))
.filter((entry): entry is ChannelPluginCatalogEntry => Boolean(entry));

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
const STARTUP_METADATA_FILE = "cli-startup-metadata.json";
const startupMetadataByPath = new Map<string, Record<string, unknown> | null>();
function resolveStartupMetadataPathCandidates(moduleUrl: string): string[] {
const moduleDir = path.dirname(fileURLToPath(moduleUrl));
@@ -14,10 +15,20 @@ function resolveStartupMetadataPathCandidates(moduleUrl: string): string[] {
export function readCliStartupMetadata(moduleUrl: string): Record<string, unknown> | null {
for (const metadataPath of resolveStartupMetadataPathCandidates(moduleUrl)) {
const cached = startupMetadataByPath.get(metadataPath);
if (cached !== undefined) {
if (cached) {
return cached;
}
continue;
}
try {
return JSON.parse(fs.readFileSync(metadataPath, "utf8")) as Record<string, unknown>;
const parsed = JSON.parse(fs.readFileSync(metadataPath, "utf8")) as Record<string, unknown>;
startupMetadataByPath.set(metadataPath, parsed);
return parsed;
} catch {
// Try the next bundled/source layout before falling back to dynamic startup work.
startupMetadataByPath.set(metadataPath, null);
}
}
return null;
@@ -25,4 +36,7 @@ export function readCliStartupMetadata(moduleUrl: string): Record<string, unknow
export const __testing = {
resolveStartupMetadataPathCandidates,
clearStartupMetadataCache(): void {
startupMetadataByPath.clear();
},
};

View File

@@ -108,13 +108,18 @@ vi.mock("./openclaw-root.fs.runtime.js", () => ({
describe("resolveOpenClawPackageRoot", () => {
let resolveOpenClawPackageRoot: typeof import("./openclaw-root.js").resolveOpenClawPackageRoot;
let resolveOpenClawPackageRootSync: typeof import("./openclaw-root.js").resolveOpenClawPackageRootSync;
let clearOpenClawPackageRootCaches: typeof import("./openclaw-root.js").__testing.clearOpenClawPackageRootCaches;
beforeAll(async () => {
({ resolveOpenClawPackageRoot, resolveOpenClawPackageRootSync } =
await import("./openclaw-root.js"));
({
resolveOpenClawPackageRoot,
resolveOpenClawPackageRootSync,
__testing: { clearOpenClawPackageRootCaches },
} = await import("./openclaw-root.js"));
});
beforeEach(() => {
clearOpenClawPackageRootCaches();
state.entries.clear();
state.realpaths.clear();
state.realpathErrors.clear();

View File

@@ -3,6 +3,9 @@ import { fileURLToPath } from "node:url";
import { openClawRootFs, openClawRootFsSync } from "./openclaw-root.fs.runtime.js";
const CORE_PACKAGE_NAMES = new Set(["openclaw"]);
const packageNameCache = new Map<string, string | null>();
const packageRootCache = new Map<string, string | null>();
const argv1CandidateCache = new Map<string, string[]>();
function parsePackageName(raw: string): string | null {
const parsed = JSON.parse(raw) as { name?: unknown };
@@ -10,19 +13,31 @@ function parsePackageName(raw: string): string | null {
}
async function readPackageName(dir: string): Promise<string | null> {
const packageJsonPath = path.join(path.resolve(dir), "package.json");
if (packageNameCache.has(packageJsonPath)) {
return packageNameCache.get(packageJsonPath) ?? null;
}
try {
return parsePackageName(await openClawRootFs.readFile(path.join(dir, "package.json"), "utf-8"));
const name = parsePackageName(await openClawRootFs.readFile(packageJsonPath, "utf-8"));
packageNameCache.set(packageJsonPath, name);
return name;
} catch {
packageNameCache.set(packageJsonPath, null);
return null;
}
}
function readPackageNameSync(dir: string): string | null {
const packageJsonPath = path.join(path.resolve(dir), "package.json");
if (packageNameCache.has(packageJsonPath)) {
return packageNameCache.get(packageJsonPath) ?? null;
}
try {
return parsePackageName(
openClawRootFsSync.readFileSync(path.join(dir, "package.json"), "utf-8"),
);
const name = parsePackageName(openClawRootFsSync.readFileSync(packageJsonPath, "utf-8"));
packageNameCache.set(packageJsonPath, name);
return name;
} catch {
packageNameCache.set(packageJsonPath, null);
return null;
}
}
@@ -60,6 +75,11 @@ function* iterAncestorDirs(startDir: string, maxDepth: number): Generator<string
}
function candidateDirsFromArgv1(argv1: string): string[] {
const cacheKey = path.resolve(argv1);
const cached = argv1CandidateCache.get(cacheKey);
if (cached) {
return [...cached];
}
const normalized = path.resolve(argv1);
const candidates = [path.dirname(normalized)];
@@ -81,7 +101,9 @@ function candidateDirsFromArgv1(argv1: string): string[] {
const nodeModulesDir = parts.slice(0, binIndex).join(path.sep);
candidates.push(path.join(nodeModulesDir, binName));
}
return candidates;
const deduped = dedupeCandidates(candidates);
argv1CandidateCache.set(cacheKey, deduped);
return [...deduped];
}
export async function resolveOpenClawPackageRoot(opts: {
@@ -89,13 +111,20 @@ export async function resolveOpenClawPackageRoot(opts: {
argv1?: string;
moduleUrl?: string;
}): Promise<string | null> {
for (const candidate of buildCandidates(opts)) {
const candidates = buildCandidates(opts);
const cacheKey = createPackageRootCacheKey(candidates);
if (packageRootCache.has(cacheKey)) {
return packageRootCache.get(cacheKey) ?? null;
}
for (const candidate of candidates) {
const found = await findPackageRoot(candidate);
if (found) {
packageRootCache.set(cacheKey, found);
return found;
}
}
packageRootCache.set(cacheKey, null);
return null;
}
@@ -104,13 +133,20 @@ export function resolveOpenClawPackageRootSync(opts: {
argv1?: string;
moduleUrl?: string;
}): string | null {
for (const candidate of buildCandidates(opts)) {
const candidates = buildCandidates(opts);
const cacheKey = createPackageRootCacheKey(candidates);
if (packageRootCache.has(cacheKey)) {
return packageRootCache.get(cacheKey) ?? null;
}
for (const candidate of candidates) {
const found = findPackageRootSync(candidate);
if (found) {
packageRootCache.set(cacheKey, found);
return found;
}
}
packageRootCache.set(cacheKey, null);
return null;
}
@@ -131,5 +167,31 @@ function buildCandidates(opts: { cwd?: string; argv1?: string; moduleUrl?: strin
candidates.push(opts.cwd);
}
return candidates;
return dedupeCandidates(candidates);
}
function dedupeCandidates(candidates: readonly string[]): string[] {
const seen = new Set<string>();
const deduped: string[] = [];
for (const candidate of candidates) {
const resolved = path.resolve(candidate);
if (seen.has(resolved)) {
continue;
}
seen.add(resolved);
deduped.push(resolved);
}
return deduped;
}
function createPackageRootCacheKey(candidates: readonly string[]): string {
return candidates.join("\0");
}
export const __testing = {
clearOpenClawPackageRootCaches(): void {
packageNameCache.clear();
packageRootCache.clear();
argv1CandidateCache.clear();
},
};

View File

@@ -358,6 +358,24 @@ describe("resolveBundledPluginsDir", () => {
expect(fs.readdirSync(bundledDir ?? "")).toEqual([]);
});
it("separates tilde override cache entries by OPENCLAW_HOME", () => {
const homeA = makeRepoRoot("openclaw-bundled-dir-home-a-");
const homeB = makeRepoRoot("openclaw-bundled-dir-home-b-");
seedBundledPluginTree(homeA, "bundled", "memory-core");
seedBundledPluginTree(homeB, "bundled", "discord");
const envBase = {
OPENCLAW_BUNDLED_PLUGINS_DIR: "~/bundled",
OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1",
VITEST: "true",
} satisfies NodeJS.ProcessEnv;
const bundledA = resolveBundledPluginsDir({ ...envBase, OPENCLAW_HOME: homeA });
const bundledB = resolveBundledPluginsDir({ ...envBase, OPENCLAW_HOME: homeB });
expect(fs.realpathSync(bundledA ?? "")).toBe(fs.realpathSync(path.join(homeA, "bundled")));
expect(fs.realpathSync(bundledB ?? "")).toBe(fs.realpathSync(path.join(homeB, "bundled")));
});
it("ignores an existing override under an argv1-derived fake package root", () => {
const installedRoot = createOpenClawRoot({
prefix: "openclaw-bundled-dir-argv-override-reject-",

View File

@@ -9,6 +9,7 @@ import { resolveUserPath } from "../utils.js";
const DISABLED_BUNDLED_PLUGINS_DIR = path.join(os.tmpdir(), "openclaw-empty-bundled-plugins");
const TEST_TRUST_BUNDLED_PLUGINS_DIR_ENV = "OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR";
let bundledPluginsDirOverrideForTest: string | undefined;
const bundledPluginsDirCache = new Map<string, string | undefined>();
export type SourceCheckoutDependencyDiagnostic = {
source: string;
@@ -192,7 +193,25 @@ function resolveBundledDirFromPackageRoot(packageRoot: string): string | undefin
return undefined;
}
export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): string | undefined {
function createBundledPluginsDirCacheKey(env: NodeJS.ProcessEnv): string {
return JSON.stringify({
disabled: env.OPENCLAW_DISABLE_BUNDLED_PLUGINS ?? "",
override: env.OPENCLAW_BUNDLED_PLUGINS_DIR ?? "",
trustOverride: env[TEST_TRUST_BUNDLED_PLUGINS_DIR_ENV] ?? "",
processTrustOverride: process.env[TEST_TRUST_BUNDLED_PLUGINS_DIR_ENV] ?? "",
vitest: env.VITEST ?? "",
processVitest: process.env.VITEST ?? "",
nodeEnv: process.env.NODE_ENV ?? "",
argv1: process.argv[1] ?? "",
execPath: process.execPath,
openClawHome: env.OPENCLAW_HOME ?? "",
home: env.HOME ?? "",
userProfile: env.USERPROFILE ?? "",
testOverride: bundledPluginsDirOverrideForTest ?? "",
});
}
function resolveBundledPluginsDirUncached(env: NodeJS.ProcessEnv): string | undefined {
if (areBundledPluginsDisabled(env)) {
return resolveDisabledBundledPluginsDir();
}
@@ -278,9 +297,20 @@ export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env):
return undefined;
}
export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): string | undefined {
const cacheKey = createBundledPluginsDirCacheKey(env);
if (bundledPluginsDirCache.has(cacheKey)) {
return bundledPluginsDirCache.get(cacheKey);
}
const resolved = resolveBundledPluginsDirUncached(env);
bundledPluginsDirCache.set(cacheKey, resolved);
return resolved;
}
export function setBundledPluginsDirOverrideForTest(dir: string | undefined): void {
if (process.env.VITEST !== "true" && process.env.NODE_ENV !== "test") {
throw new Error("setBundledPluginsDirOverrideForTest is only available in tests");
}
bundledPluginsDirOverrideForTest = dir;
bundledPluginsDirCache.clear();
}

View File

@@ -2,6 +2,12 @@ import crypto from "node:crypto";
import fs from "node:fs";
import type { PluginDiagnostic } from "./manifest-types.js";
export type InstalledPluginFileSignature = {
size: number;
mtimeMs: number;
ctimeMs?: number;
};
function hashString(value: string): string {
return crypto.createHash("sha256").update(value).digest("hex");
}
@@ -32,3 +38,40 @@ export function safeHashFile(params: {
return undefined;
}
}
export function safeFileSignature(filePath: string): InstalledPluginFileSignature | undefined {
try {
const stat = fs.statSync(filePath);
if (!stat.isFile()) {
return undefined;
}
return {
size: stat.size,
mtimeMs: stat.mtimeMs,
ctimeMs: stat.ctimeMs,
};
} catch {
return undefined;
}
}
export function fileSignatureMatches(
filePath: string,
signature: InstalledPluginFileSignature | undefined,
): boolean | undefined {
if (!signature) {
return undefined;
}
if (typeof signature.ctimeMs !== "number") {
return undefined;
}
const current = safeFileSignature(filePath);
if (!current) {
return false;
}
return (
current.size === signature.size &&
current.mtimeMs === signature.mtimeMs &&
current.ctimeMs === signature.ctimeMs
);
}

View File

@@ -6,7 +6,7 @@ import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-st
import type { PluginCandidate } from "./discovery.js";
import type { PluginInstallSourceInfo } from "./install-source-info.js";
import { describePluginInstallSource } from "./install-source-info.js";
import { hashJson, safeHashFile } from "./installed-plugin-index-hash.js";
import { hashJson, safeFileSignature, safeHashFile } from "./installed-plugin-index-hash.js";
import { hasOptionalMissingPluginManifestFile } from "./installed-plugin-index-manifest.js";
import type {
InstalledPluginIndexRecord,
@@ -109,9 +109,11 @@ function resolvePackageJsonRecord(params: {
if (!hash) {
return undefined;
}
const fileSignature = safeFileSignature(params.packageJsonPath);
return {
path: resolvePackageJsonRelativePath(params.candidate.rootDir, params.packageJsonPath),
hash,
...(fileSignature ? { fileSignature } : {}),
};
}
@@ -210,6 +212,9 @@ export function buildInstalledPluginIndexRecords(params: {
record.packageChannel ?? candidate?.packageManifest?.channel,
);
const manifestHash = resolveManifestHash({ record, diagnostics: params.diagnostics });
const manifestFile = hasOptionalMissingPluginManifestFile(record)
? undefined
: safeFileSignature(record.manifestPath);
const packageJson = resolvePackageJsonRecord({
candidate,
packageJsonPath,
@@ -227,6 +232,7 @@ export function buildInstalledPluginIndexRecords(params: {
pluginId: record.id,
manifestPath: record.manifestPath,
manifestHash,
...(manifestFile ? { manifestFile } : {}),
source: record.source,
rootDir: record.rootDir,
origin: record.origin,

View File

@@ -50,6 +50,12 @@ const InstalledPluginIndexStartupSchema = z.object({
agentHarnesses: StringArraySchema,
});
const InstalledPluginFileSignatureSchema = z.object({
size: z.number(),
mtimeMs: z.number(),
ctimeMs: z.number().optional(),
});
const InstalledPluginIndexRecordSchema = z.object({
pluginId: z.string(),
packageName: z.string().optional(),
@@ -60,6 +66,7 @@ const InstalledPluginIndexRecordSchema = z.object({
packageChannel: z.unknown().optional(),
manifestPath: z.string(),
manifestHash: z.string(),
manifestFile: InstalledPluginFileSignatureSchema.optional(),
format: z.string().optional(),
bundleFormat: z.string().optional(),
source: z.string().optional(),
@@ -68,6 +75,7 @@ const InstalledPluginIndexRecordSchema = z.object({
.object({
path: z.string(),
hash: z.string(),
fileSignature: InstalledPluginFileSignatureSchema.optional(),
})
.optional(),
rootDir: z.string(),

View File

@@ -3,6 +3,7 @@ import type { PluginInstallRecord } from "../config/types.plugins.js";
import type { PluginCompatCode } from "./compat/registry.js";
import type { PluginCandidate } from "./discovery.js";
import type { PluginInstallSourceInfo } from "./install-source-info.js";
import type { InstalledPluginFileSignature } from "./installed-plugin-index-hash.js";
import type { PluginManifestRecord } from "./manifest-registry.js";
import type { PluginDiagnostic } from "./manifest-types.js";
import type { PluginPackageChannel } from "./manifest.js";
@@ -81,6 +82,7 @@ export type InstalledPluginIndexRecord = {
packageChannel?: InstalledPluginPackageChannelInfo;
manifestPath: string;
manifestHash: string;
manifestFile?: InstalledPluginFileSignature;
format?: PluginManifestRecord["format"];
bundleFormat?: PluginManifestRecord["bundleFormat"];
source?: string;
@@ -88,6 +90,7 @@ export type InstalledPluginIndexRecord = {
packageJson?: {
path: string;
hash: string;
fileSignature?: InstalledPluginFileSignature;
};
rootDir: string;
origin: PluginManifestRecord["origin"];

View File

@@ -690,6 +690,17 @@ function readPackageVersionForCache(packageJsonPath: string): string {
}
}
const bundledPackageCacheIdentityByStockRoot = new Map<
string,
{
packageJson: string;
packageRoot: string;
packageVersion: string;
size: number;
mtimeMs: number;
}
>();
function resolveBundledPackageCacheIdentity(stockRoot?: string):
| {
packageJson: string;
@@ -703,24 +714,33 @@ function resolveBundledPackageCacheIdentity(stockRoot?: string):
if (!packageRoot) {
return undefined;
}
const stockRootKey = path.resolve(stockRoot);
const cached = bundledPackageCacheIdentityByStockRoot.get(stockRootKey);
if (cached) {
return cached;
}
const packageJsonPath = path.join(packageRoot, "package.json");
try {
const stat = fs.statSync(packageJsonPath);
return {
const identity = {
packageJson: safeRealpathOrResolve(packageJsonPath),
packageRoot: safeRealpathOrResolve(packageRoot),
packageVersion: readPackageVersionForCache(packageJsonPath),
size: stat.size,
mtimeMs: stat.mtimeMs,
};
bundledPackageCacheIdentityByStockRoot.set(stockRootKey, identity);
return identity;
} catch {
return {
const identity = {
packageJson: path.resolve(packageJsonPath),
packageRoot: safeRealpathOrResolve(packageRoot),
packageVersion: "missing",
size: -1,
mtimeMs: -1,
};
bundledPackageCacheIdentityByStockRoot.set(stockRootKey, identity);
return identity;
}
}

View File

@@ -1,6 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { writePersistedInstalledPluginIndexSync } from "./installed-plugin-index-store.js";
import { loadInstalledPluginIndex, type InstalledPluginIndex } from "./installed-plugin-index.js";
import { loadPluginRegistrySnapshotWithMetadata } from "./plugin-registry-snapshot.js";
@@ -9,6 +9,7 @@ import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fi
const tempDirs: string[] = [];
afterEach(() => {
vi.restoreAllMocks();
cleanupTrackedTempDirs(tempDirs);
});
@@ -29,6 +30,33 @@ function writeManifestlessClaudeBundle(rootDir: string) {
fs.writeFileSync(path.join(rootDir, "skills", "SKILL.md"), "# Workspace skill\n", "utf8");
}
function writePackagePlugin(rootDir: string) {
fs.mkdirSync(rootDir, { recursive: true });
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default { register() {} };\n", "utf8");
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: "demo",
name: "Demo",
description: "one",
configSchema: { type: "object" },
}),
"utf8",
);
fs.writeFileSync(
path.join(rootDir, "package.json"),
JSON.stringify({ name: "demo", version: "1.0.0" }),
"utf8",
);
}
function replaceFilePreservingSizeAndMtime(filePath: string, contents: string) {
const previous = fs.statSync(filePath);
expect(Buffer.byteLength(contents)).toBe(previous.size);
fs.writeFileSync(filePath, contents, "utf8");
fs.utimesSync(filePath, previous.atime, previous.mtime);
}
function createManifestlessClaudeBundleIndex(params: {
rootDir: string;
env: NodeJS.ProcessEnv;
@@ -48,7 +76,7 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
const tempRoot = makeTempDir();
const rootDir = path.join(tempRoot, "workspace");
const stateDir = path.join(tempRoot, "state");
const env = createHermeticEnv(tempRoot);
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
const config = {
plugins: {
load: { paths: [rootDir] },
@@ -67,4 +95,106 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
expect(result.source).toBe("persisted");
expect(result.diagnostics).toEqual([]);
});
it("keeps persisted package plugins on the fast path when file signatures match", () => {
const tempRoot = makeTempDir();
const rootDir = path.join(tempRoot, "workspace");
const stateDir = path.join(tempRoot, "state");
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
const config = {
plugins: {
load: { paths: [rootDir] },
},
};
writePackagePlugin(rootDir);
const index = loadInstalledPluginIndex({ config, env });
const [record] = index.plugins;
expect(record?.manifestFile).toBeDefined();
expect(record?.packageJson?.fileSignature).toBeDefined();
writePersistedInstalledPluginIndexSync(index, { stateDir });
const readFileSyncSpy = vi.spyOn(fs, "readFileSync");
const result = loadPluginRegistrySnapshotWithMetadata({
config,
env,
stateDir,
});
const pluginMetadataFileReads = readFileSyncSpy.mock.calls.filter((call) => {
const filePath = String(call[0]);
return (
filePath === path.join(rootDir, "openclaw.plugin.json") ||
filePath === path.join(rootDir, "package.json")
);
});
expect(result.source).toBe("persisted");
expect(pluginMetadataFileReads).toEqual([]);
});
it("detects same-size same-mtime manifest replacements", () => {
const tempRoot = makeTempDir();
const rootDir = path.join(tempRoot, "workspace");
const stateDir = path.join(tempRoot, "state");
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
const config = {
plugins: {
load: { paths: [rootDir] },
},
};
writePackagePlugin(rootDir);
const index = loadInstalledPluginIndex({ config, env });
writePersistedInstalledPluginIndexSync(index, { stateDir });
replaceFilePreservingSizeAndMtime(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: "demo",
name: "Demo",
description: "two",
configSchema: { type: "object" },
}),
);
const result = loadPluginRegistrySnapshotWithMetadata({
config,
env,
stateDir,
});
expect(result.source).toBe("derived");
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "persisted-registry-stale-source" }),
);
});
it("detects same-size same-mtime package.json replacements", () => {
const tempRoot = makeTempDir();
const rootDir = path.join(tempRoot, "workspace");
const stateDir = path.join(tempRoot, "state");
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
const config = {
plugins: {
load: { paths: [rootDir] },
},
};
writePackagePlugin(rootDir);
const index = loadInstalledPluginIndex({ config, env });
writePersistedInstalledPluginIndexSync(index, { stateDir });
replaceFilePreservingSizeAndMtime(
path.join(rootDir, "package.json"),
JSON.stringify({ name: "demo", version: "1.0.1" }),
);
const result = loadPluginRegistrySnapshotWithMetadata({
config,
env,
stateDir,
});
expect(result.source).toBe("derived");
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "persisted-registry-stale-source" }),
);
});
});

View File

@@ -2,6 +2,7 @@ import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { resolveBundledPluginsDir } from "./bundled-dir.js";
import { fileSignatureMatches } from "./installed-plugin-index-hash.js";
import { hasOptionalMissingPluginManifestFile } from "./installed-plugin-index-manifest.js";
import {
inspectPersistedInstalledPluginIndex,
@@ -132,9 +133,22 @@ function resolveRecordPackageJsonPath(plugin: InstalledPluginIndexRecord): strin
function hasStalePersistedPluginMetadata(index: InstalledPluginIndex): boolean {
return index.plugins.some((plugin) => {
if (!hasOptionalMissingPluginManifestFile(plugin)) {
const manifestHash = hashExistingFile(plugin.manifestPath);
if (manifestHash && manifestHash !== plugin.manifestHash) {
return true;
const manifestSignatureMatches = fileSignatureMatches(
plugin.manifestPath,
plugin.manifestFile,
);
if (manifestSignatureMatches === true) {
// Stored stat signature is unchanged; avoid hashing on startup.
} else if (manifestSignatureMatches === false) {
const manifestHash = hashExistingFile(plugin.manifestPath);
if (manifestHash && manifestHash !== plugin.manifestHash) {
return true;
}
} else {
const manifestHash = hashExistingFile(plugin.manifestPath);
if (manifestHash && manifestHash !== plugin.manifestHash) {
return true;
}
}
}
const packageJsonPath = resolveRecordPackageJsonPath(plugin);
@@ -144,6 +158,16 @@ function hasStalePersistedPluginMetadata(index: InstalledPluginIndex): boolean {
if (!packageJsonPath) {
return true;
}
const packageJsonSignatureMatches = fileSignatureMatches(
packageJsonPath,
plugin.packageJson.fileSignature,
);
if (packageJsonSignatureMatches === true) {
return false;
}
if (packageJsonSignatureMatches === false) {
return hashExistingFile(packageJsonPath) !== plugin.packageJson.hash;
}
const packageJsonHash = hashExistingFile(packageJsonPath);
return packageJsonHash !== plugin.packageJson.hash;
});

View File

@@ -22,6 +22,7 @@ type PluginSdkPackageJson = {
};
const STARTUP_ARGV1 = process.argv[1];
const pluginSdkPackageJsonByRoot = new Map<string, PluginSdkPackageJson | null>();
export function normalizeJitiAliasTargetPath(targetPath: string): string {
return process.platform === "win32" ? targetPath.replace(/\\/g, "/") : targetPath;
@@ -32,10 +33,17 @@ function resolveLoaderModulePath(params: LoaderModuleResolveParams = {}): string
}
function readPluginSdkPackageJson(packageRoot: string): PluginSdkPackageJson | null {
const cacheKey = path.resolve(packageRoot);
if (pluginSdkPackageJsonByRoot.has(cacheKey)) {
return pluginSdkPackageJsonByRoot.get(cacheKey) ?? null;
}
try {
const pkgRaw = fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8");
return JSON.parse(pkgRaw) as PluginSdkPackageJson;
const parsed = JSON.parse(pkgRaw) as PluginSdkPackageJson;
pluginSdkPackageJsonByRoot.set(cacheKey, parsed);
return parsed;
} catch {
pluginSdkPackageJsonByRoot.set(cacheKey, null);
return null;
}
}