fix(plugins): index install ledger source facts

This commit is contained in:
Vincent Koc
2026-04-24 23:42:34 -07:00
parent 15d27d1527
commit 28eb56dd21
2 changed files with 196 additions and 6 deletions

View File

@@ -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",
]);

View File

@@ -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<Key extends keyof Omit<InstalledPluginInstallRecordInfo, "source">>(
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<string, PluginCandidate> {
@@ -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");
}