From 28eb56dd21b61631ba916ea3dd0fc2afa002f31d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 24 Apr 2026 23:42:34 -0700 Subject: [PATCH] fix(plugins): index install ledger source facts --- src/plugins/installed-plugin-index.test.ts | 116 ++++++++++++++++++++- src/plugins/installed-plugin-index.ts | 86 ++++++++++++++- 2 files changed, 196 insertions(+), 6 deletions(-) diff --git a/src/plugins/installed-plugin-index.test.ts b/src/plugins/installed-plugin-index.test.ts index f0d17c600e1..dc31b7822ce 100644 --- a/src/plugins/installed-plugin-index.test.ts +++ b/src/plugins/installed-plugin-index.test.ts @@ -156,7 +156,7 @@ describe("installed plugin index", () => { origin: "global", rootDir: fixture.rootDir, enabled: true, - sourceFacts: { + packageInstall: { defaultChoice: "npm", npm: { spec: "@vendor/demo-plugin@1.2.3", @@ -191,6 +191,7 @@ describe("installed plugin index", () => { expect(index.plugins[0]?.manifestHash).toMatch(/^[a-f0-9]{64}$/u); expect(index.plugins[0]?.packageJsonHash).toMatch(/^[a-f0-9]{64}$/u); expect(index.plugins[0]?.packageJsonPath).toBe(path.join(fixture.rootDir, "package.json")); + expect(index.plugins[0]?.installRecord).toBeUndefined(); const contributions = resolveInstalledPluginContributions(index); expect(contributions.providers.get("demo")).toEqual(["demo"]); @@ -198,6 +199,98 @@ describe("installed plugin index", () => { expect(contributions.contracts.get("tools")).toEqual(["demo"]); }); + it("records the config install ledger separately from package install intent", () => { + const fixture = createRichPluginFixture(); + + const index = loadInstalledPluginIndex({ + candidates: [fixture.candidate], + config: { + plugins: { + installs: { + demo: { + source: "npm", + spec: "@vendor/demo-plugin@latest", + installPath: "plugins/demo", + resolvedName: "@vendor/demo-plugin", + resolvedVersion: "1.2.3", + resolvedSpec: "@vendor/demo-plugin@1.2.3", + integrity: "sha512-installed", + shasum: "abc123", + resolvedAt: "2026-04-25T11:00:00.000Z", + installedAt: "2026-04-25T11:01:00.000Z", + }, + }, + }, + }, + env: hermeticEnv(), + }); + + expect(index.plugins[0]).toMatchObject({ + installRecord: { + source: "npm", + spec: "@vendor/demo-plugin@latest", + installPath: "plugins/demo", + resolvedName: "@vendor/demo-plugin", + resolvedVersion: "1.2.3", + resolvedSpec: "@vendor/demo-plugin@1.2.3", + integrity: "sha512-installed", + shasum: "abc123", + resolvedAt: "2026-04-25T11:00:00.000Z", + installedAt: "2026-04-25T11:01:00.000Z", + }, + packageInstall: { + npm: { + spec: "@vendor/demo-plugin@1.2.3", + expectedIntegrity: "sha512-demo", + pinState: "exact-with-integrity", + }, + }, + }); + expect(index.plugins[0]?.installRecordHash).toMatch(/^[a-f0-9]{64}$/u); + }); + + it("treats install ledger changes as source invalidation", () => { + const fixture = createRichPluginFixture(); + const previous = loadInstalledPluginIndex({ + candidates: [fixture.candidate], + config: { + plugins: { + installs: { + demo: { + source: "npm", + resolvedName: "@vendor/demo-plugin", + resolvedVersion: "1.2.3", + resolvedSpec: "@vendor/demo-plugin@1.2.3", + integrity: "sha512-old", + }, + }, + }, + }, + env: hermeticEnv(), + }); + const current = loadInstalledPluginIndex({ + candidates: [fixture.candidate], + config: { + plugins: { + installs: { + demo: { + source: "npm", + resolvedName: "@vendor/demo-plugin", + resolvedVersion: "1.2.3", + resolvedSpec: "@vendor/demo-plugin@1.2.3", + integrity: "sha512-new", + }, + }, + }, + }, + env: hermeticEnv(), + }); + + expect(diffInstalledPluginIndexInvalidationReasons(previous, current)).toEqual([ + "source-changed", + ]); + }); + it("marks disabled plugins without dropping their cold contributions", () => { const fixture = createRichPluginFixture(); @@ -235,6 +328,16 @@ describe("installed plugin index", () => { const fixture = createRichPluginFixture(); const previous = loadInstalledPluginIndex({ candidates: [fixture.candidate], + config: { + plugins: { + installs: { + demo: { + source: "npm", + resolvedVersion: "1.2.3", + }, + }, + }, + }, env: hermeticEnv({ OPENCLAW_VERSION: "2026.4.25" }), }); @@ -255,6 +358,16 @@ describe("installed plugin index", () => { packageVersion: "1.2.4", }, ], + config: { + plugins: { + installs: { + demo: { + source: "npm", + resolvedVersion: "1.2.4", + }, + }, + }, + }, env: hermeticEnv({ OPENCLAW_VERSION: "2026.4.26" }), }), compatRegistryVersion: "different-compat-registry", @@ -263,6 +376,7 @@ describe("installed plugin index", () => { expect(diffInstalledPluginIndexInvalidationReasons(previous, current)).toEqual([ "compat-registry-changed", "host-contract-changed", + "source-changed", "stale-manifest", "stale-package", ]); diff --git a/src/plugins/installed-plugin-index.ts b/src/plugins/installed-plugin-index.ts index 28e7693c1bc..1041c7f61ed 100644 --- a/src/plugins/installed-plugin-index.ts +++ b/src/plugins/installed-plugin-index.ts @@ -2,6 +2,7 @@ import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import type { OpenClawConfig } from "../config/types.js"; +import type { PluginInstallRecord } from "../config/types.plugins.js"; import { resolveCompatibilityHostVersion } from "../version.js"; import { listPluginCompatRecords, type PluginCompatCode } from "./compat/registry.js"; import { @@ -43,11 +44,36 @@ export type InstalledPluginIndexContributions = { contracts: readonly string[]; }; +export type InstalledPluginInstallRecordInfo = Pick< + PluginInstallRecord, + | "source" + | "spec" + | "sourcePath" + | "installPath" + | "version" + | "resolvedName" + | "resolvedVersion" + | "resolvedSpec" + | "integrity" + | "shasum" + | "resolvedAt" + | "installedAt" + | "clawhubUrl" + | "clawhubPackage" + | "clawhubFamily" + | "clawhubChannel" + | "marketplaceName" + | "marketplaceSource" + | "marketplacePlugin" +>; + export type InstalledPluginIndexRecord = { pluginId: string; packageName?: string; packageVersion?: string; - sourceFacts?: PluginInstallSourceInfo; + installRecord?: InstalledPluginInstallRecordInfo; + installRecordHash?: string; + packageInstall?: PluginInstallSourceInfo; manifestPath: string; manifestHash: string; packageJsonPath?: string; @@ -215,6 +241,50 @@ function describePackageInstallSource( }); } +function setInstallStringField>( + target: InstalledPluginInstallRecordInfo, + key: Key, + value: PluginInstallRecord[Key], +): void { + if (typeof value !== "string") { + return; + } + const normalized = value.trim(); + if (normalized) { + target[key] = normalized as InstalledPluginInstallRecordInfo[Key]; + } +} + +function normalizeInstallRecord( + record: PluginInstallRecord | undefined, +): InstalledPluginInstallRecordInfo | undefined { + if (!record) { + return undefined; + } + const normalized: InstalledPluginInstallRecordInfo = { + source: record.source, + }; + setInstallStringField(normalized, "spec", record.spec); + setInstallStringField(normalized, "sourcePath", record.sourcePath); + setInstallStringField(normalized, "installPath", record.installPath); + setInstallStringField(normalized, "version", record.version); + setInstallStringField(normalized, "resolvedName", record.resolvedName); + setInstallStringField(normalized, "resolvedVersion", record.resolvedVersion); + setInstallStringField(normalized, "resolvedSpec", record.resolvedSpec); + setInstallStringField(normalized, "integrity", record.integrity); + setInstallStringField(normalized, "shasum", record.shasum); + setInstallStringField(normalized, "resolvedAt", record.resolvedAt); + setInstallStringField(normalized, "installedAt", record.installedAt); + setInstallStringField(normalized, "clawhubUrl", record.clawhubUrl); + setInstallStringField(normalized, "clawhubPackage", record.clawhubPackage); + setInstallStringField(normalized, "clawhubFamily", record.clawhubFamily); + setInstallStringField(normalized, "clawhubChannel", record.clawhubChannel); + setInstallStringField(normalized, "marketplaceName", record.marketplaceName); + setInstallStringField(normalized, "marketplaceSource", record.marketplaceSource); + setInstallStringField(normalized, "marketplacePlugin", record.marketplacePlugin); + return normalized; +} + function buildCandidateLookup( candidates: readonly PluginCandidate[], ): Map { @@ -288,7 +358,8 @@ function buildInstalledPluginIndex( const plugins = registry.plugins.map((record): InstalledPluginIndexRecord => { const candidate = candidateByRootDir.get(record.rootDir); const packageJsonPath = resolvePackageJsonPath(candidate); - const sourceFacts = describePackageInstallSource(candidate); + const installRecord = normalizeInstallRecord(params.config?.plugins?.installs?.[record.id]); + const packageInstall = describePackageInstallSource(candidate); const manifestHash = safeHashFile({ filePath: record.manifestPath, @@ -328,8 +399,12 @@ function buildInstalledPluginIndex( if (candidate?.packageVersion) { indexRecord.packageVersion = candidate.packageVersion; } - if (sourceFacts) { - indexRecord.sourceFacts = sourceFacts; + if (installRecord) { + indexRecord.installRecord = installRecord; + indexRecord.installRecordHash = hashJson(installRecord); + } + if (packageInstall) { + indexRecord.packageInstall = packageInstall; } if (packageJsonPath) { indexRecord.packageJsonPath = packageJsonPath; @@ -462,7 +537,8 @@ export function diffInstalledPluginIndexInvalidationReasons( } if ( previousPlugin.rootDir !== currentPlugin.rootDir || - previousPlugin.manifestPath !== currentPlugin.manifestPath + previousPlugin.manifestPath !== currentPlugin.manifestPath || + previousPlugin.installRecordHash !== currentPlugin.installRecordHash ) { reasons.add("source-changed"); }