Files
openclaw/src/config/plugin-install-record-map.ts
T
Vincent Koc 1471881af7 fix(plugins): preserve install records during recovery (#121045)
* fix(plugins): canonicalize install record persistence

Preserve canonical fields, passthrough metadata, untouched record identity, and deterministic serialized ordering across SQLite and post-core handoff.

Punchcard-Session: amber-workshop-workshop-36
Co-authored-by: Galin Iliev <5711535+galiniliev@users.noreply.github.com>

* fix(doctor): block invalid plugin record mutations

Preflight persisted and legacy install-record state before registry repair, package cleanup, generation retirement, archival, or state-directory moves.

Punchcard-Session: amber-workshop-workshop-36
Co-authored-by: Galin Iliev <5711535+galiniliev@users.noreply.github.com>

* fix(plugins): harden install record map ownership

Punchcard-Session: amber-workshop-workshop-36
Co-authored-by: Galin Iliev <5711535+galiniliev@users.noreply.github.com>

* fix(update): preserve special plugin ids in payload checks

Punchcard-Session: amber-workshop-workshop-36
Co-authored-by: Galin Iliev <5711535+galiniliev@users.noreply.github.com>

* test(doctor): assert canonical install record maps

Punchcard-Session: amber-workshop-workshop-36
Co-authored-by: Galin Iliev <5711535+galiniliev@users.noreply.github.com>

* test(doctor): preserve invalid plugin index fixtures

Punchcard-Session: amber-workshop-workshop-36
Co-authored-by: Galin Iliev <5711535+galiniliev@users.noreply.github.com>

---------

Co-authored-by: Galin Iliev <5711535+galiniliev@users.noreply.github.com>
2026-08-09 20:24:40 +08:00

164 lines
4.7 KiB
TypeScript

import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { z } from "zod";
import type { PluginInstallRecord } from "./types.plugins.js";
import { PluginInstallRecordShape } from "./zod-schema.installs.js";
export const PluginInstallRecordSchema = z.object(PluginInstallRecordShape).passthrough();
const NORMALIZED_STRING_FIELDS = [
"spec",
"sourcePath",
"installPath",
"version",
"resolvedName",
"resolvedVersion",
"resolvedSpec",
"integrity",
"shasum",
"resolvedAt",
"installedAt",
"clawhubUrl",
"clawhubPackage",
"clawhubFamily",
"clawhubChannel",
"clawhubTrustDisposition",
"clawhubTrustScanStatus",
"clawhubTrustModerationState",
"clawhubTrustCheckedAt",
"clawhubTrustAcknowledgedAt",
"artifactKind",
"artifactFormat",
"npmIntegrity",
"npmShasum",
"npmTarballName",
"clawpackSha256",
"clawpackManifestSha256",
"gitUrl",
"gitRef",
"gitCommit",
"marketplaceName",
"marketplaceSource",
"marketplacePlugin",
] as const satisfies readonly (keyof PluginInstallRecord)[];
export type PluginInstallRecordMapState =
| { status: "missing" }
| { status: "invalid" }
| { status: "valid"; records: Record<string, PluginInstallRecord> };
const utf8Encoder = new TextEncoder();
function comparePluginIds(left: string, right: string): number {
const leftBytes = utf8Encoder.encode(left);
const rightBytes = utf8Encoder.encode(right);
const sharedLength = Math.min(leftBytes.length, rightBytes.length);
for (let index = 0; index < sharedLength; index += 1) {
const difference = (leftBytes[index] ?? 0) - (rightBytes[index] ?? 0);
if (difference !== 0) {
return difference;
}
}
return leftBytes.length - rightBytes.length;
}
export function createPluginInstallRecordMap<T>(): Record<string, T> {
return Object.create(null) as Record<string, T>;
}
export function setPluginInstallRecordMapEntry<T>(
records: Record<string, T>,
pluginId: string,
record: T,
): void {
Object.defineProperty(records, pluginId, {
configurable: true,
enumerable: true,
value: record,
writable: true,
});
}
export function getPluginInstallRecordMapEntry<T>(
records: Readonly<Record<string, T>> | undefined,
pluginId: string,
): T | undefined {
return records && Object.hasOwn(records, pluginId) ? records[pluginId] : undefined;
}
export function copyPluginInstallRecordMap<T>(
records: Readonly<Record<string, T>> | undefined,
): Record<string, T> {
const copied = createPluginInstallRecordMap<T>();
for (const [pluginId, record] of Object.entries(records ?? {})) {
setPluginInstallRecordMapEntry(copied, pluginId, record);
}
return copied;
}
export function parsePluginInstallRecord(value: unknown): PluginInstallRecord | null {
const parsed = PluginInstallRecordSchema.safeParse(value);
if (!parsed.success) {
return null;
}
const record = parsed.data as PluginInstallRecord & Record<string, unknown>;
for (const field of NORMALIZED_STRING_FIELDS) {
const fieldValue = record[field];
if (typeof fieldValue !== "string") {
continue;
}
const normalized = fieldValue.trim();
if (normalized) {
record[field] = normalized as never;
} else {
delete record[field];
}
}
if (record.clawhubTrustReasons) {
const reasons = record.clawhubTrustReasons.map((entry) => entry.trim()).filter(Boolean);
if (reasons.length > 0) {
record.clawhubTrustReasons = reasons;
} else {
delete record.clawhubTrustReasons;
}
}
return record;
}
export function parsePluginInstallRecordMap(
value: unknown,
): Record<string, PluginInstallRecord> | null {
if (!isRecord(value)) {
return null;
}
const records = createPluginInstallRecordMap<PluginInstallRecord>();
for (const [pluginId, rawRecord] of Object.entries(value)) {
const record = parsePluginInstallRecord(rawRecord);
if (!record) {
return null;
}
setPluginInstallRecordMapEntry(records, pluginId, record);
}
return records;
}
export function inspectPluginInstallRecordMap(value: unknown): PluginInstallRecordMapState {
if (value === undefined) {
return { status: "missing" };
}
const records = parsePluginInstallRecordMap(value);
return records ? { status: "valid", records } : { status: "invalid" };
}
/**
* Object enumeration reorders integer-index keys, so persisted bytes must be
* assembled from sorted entries instead of relying on object insertion order.
*/
export function serializePluginInstallRecordMap(
records: Readonly<Record<string, PluginInstallRecord>>,
): string {
return `{${Object.entries(records)
.toSorted(([left], [right]) => comparePluginIds(left, right))
.map(([pluginId, record]) => `${JSON.stringify(pluginId)}:${JSON.stringify(record)}`)
.join(",")}}`;
}