diff --git a/src/channels/bundled-channel-catalog-read.ts b/src/channels/bundled-channel-catalog-read.ts index 2248a405dd5..0fc10332424 100644 --- a/src/channels/bundled-channel-catalog-read.ts +++ b/src/channels/bundled-channel-catalog-read.ts @@ -19,6 +19,7 @@ type BundledChannelCatalogEntry = { }; const OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH = path.join("dist", "channel-catalog.json"); +const officialCatalogFileCache = new Map(); 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; } } diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index 4269bdb45f7..fd94cf8cc55 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -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(); type ManifestKey = typeof MANIFEST_KEY; @@ -145,6 +146,32 @@ function loadCatalogEntriesFromPaths(paths: Iterable): ExternalCatalogEn return entries; } +function loadOfficialCatalogEntriesFromPaths(paths: Iterable): 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)); diff --git a/src/cli/startup-metadata.ts b/src/cli/startup-metadata.ts index 3cc89087069..223af55ba92 100644 --- a/src/cli/startup-metadata.ts +++ b/src/cli/startup-metadata.ts @@ -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 | 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 | 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; + const parsed = JSON.parse(fs.readFileSync(metadataPath, "utf8")) as Record; + 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 ({ 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(); diff --git a/src/infra/openclaw-root.ts b/src/infra/openclaw-root.ts index 4f8824fb4e1..790d983a969 100644 --- a/src/infra/openclaw-root.ts +++ b/src/infra/openclaw-root.ts @@ -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(); +const packageRootCache = new Map(); +const argv1CandidateCache = new Map(); 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 { + 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 { - 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(); + 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(); + }, +}; diff --git a/src/plugins/bundled-dir.test.ts b/src/plugins/bundled-dir.test.ts index a41fcafe1a2..371deb0d965 100644 --- a/src/plugins/bundled-dir.test.ts +++ b/src/plugins/bundled-dir.test.ts @@ -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-", diff --git a/src/plugins/bundled-dir.ts b/src/plugins/bundled-dir.ts index c4122a58a32..d084a040e02 100644 --- a/src/plugins/bundled-dir.ts +++ b/src/plugins/bundled-dir.ts @@ -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(); 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(); } diff --git a/src/plugins/installed-plugin-index-hash.ts b/src/plugins/installed-plugin-index-hash.ts index 8ab07817514..c76ef940a74 100644 --- a/src/plugins/installed-plugin-index-hash.ts +++ b/src/plugins/installed-plugin-index-hash.ts @@ -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 + ); +} diff --git a/src/plugins/installed-plugin-index-record-builder.ts b/src/plugins/installed-plugin-index-record-builder.ts index 8c87b64f3eb..2504c6f2698 100644 --- a/src/plugins/installed-plugin-index-record-builder.ts +++ b/src/plugins/installed-plugin-index-record-builder.ts @@ -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, diff --git a/src/plugins/installed-plugin-index-store.ts b/src/plugins/installed-plugin-index-store.ts index 3374adc2e7c..a0c3d31619f 100644 --- a/src/plugins/installed-plugin-index-store.ts +++ b/src/plugins/installed-plugin-index-store.ts @@ -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(), diff --git a/src/plugins/installed-plugin-index-types.ts b/src/plugins/installed-plugin-index-types.ts index 2eca68e6c82..eee98adab7e 100644 --- a/src/plugins/installed-plugin-index-types.ts +++ b/src/plugins/installed-plugin-index-types.ts @@ -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"]; diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index a35acbc4c65..fcda0eb1f33 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -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; } } diff --git a/src/plugins/plugin-registry-snapshot.test.ts b/src/plugins/plugin-registry-snapshot.test.ts index 0ef2b65bd74..e39165899fd 100644 --- a/src/plugins/plugin-registry-snapshot.test.ts +++ b/src/plugins/plugin-registry-snapshot.test.ts @@ -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" }), + ); + }); }); diff --git a/src/plugins/plugin-registry-snapshot.ts b/src/plugins/plugin-registry-snapshot.ts index 1d86c5335bf..dcbe0328f54 100644 --- a/src/plugins/plugin-registry-snapshot.ts +++ b/src/plugins/plugin-registry-snapshot.ts @@ -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; }); diff --git a/src/plugins/sdk-alias.ts b/src/plugins/sdk-alias.ts index 5ba8390db93..71b778b4ae6 100644 --- a/src/plugins/sdk-alias.ts +++ b/src/plugins/sdk-alias.ts @@ -22,6 +22,7 @@ type PluginSdkPackageJson = { }; const STARTUP_ARGV1 = process.argv[1]; +const pluginSdkPackageJsonByRoot = new Map(); 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; } }