mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:20:43 +00:00
refactor(plugins): split installed plugin index modules
This commit is contained in:
34
src/plugins/installed-plugin-index-hash.ts
Normal file
34
src/plugins/installed-plugin-index-hash.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import type { PluginDiagnostic } from "./manifest-types.js";
|
||||
|
||||
export function hashString(value: string): string {
|
||||
return crypto.createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
export function hashJson(value: unknown): string {
|
||||
return hashString(JSON.stringify(value));
|
||||
}
|
||||
|
||||
export function safeHashFile(params: {
|
||||
filePath: string;
|
||||
pluginId?: string;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
required: boolean;
|
||||
}): string | undefined {
|
||||
try {
|
||||
return crypto.createHash("sha256").update(fs.readFileSync(params.filePath)).digest("hex");
|
||||
} catch (err) {
|
||||
if (params.required) {
|
||||
params.diagnostics.push({
|
||||
level: "warn",
|
||||
...(params.pluginId ? { pluginId: params.pluginId } : {}),
|
||||
source: params.filePath,
|
||||
message: `installed plugin index could not hash ${params.filePath}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
104
src/plugins/installed-plugin-index-install-records.ts
Normal file
104
src/plugins/installed-plugin-index-install-records.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import type {
|
||||
InstalledPluginIndex,
|
||||
InstalledPluginInstallRecordInfo,
|
||||
} from "./installed-plugin-index-types.js";
|
||||
|
||||
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 restoreInstallRecord(
|
||||
record: InstalledPluginInstallRecordInfo | undefined,
|
||||
): PluginInstallRecord | undefined {
|
||||
if (!record?.source) {
|
||||
return undefined;
|
||||
}
|
||||
return structuredClone(record) as PluginInstallRecord;
|
||||
}
|
||||
|
||||
export function normalizeInstallRecordMap(
|
||||
records: Record<string, PluginInstallRecord> | undefined,
|
||||
): Record<string, InstalledPluginInstallRecordInfo> {
|
||||
const normalized: Record<string, InstalledPluginInstallRecordInfo> = {};
|
||||
for (const [pluginId, record] of Object.entries(records ?? {}).toSorted(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
)) {
|
||||
const installRecord = normalizeInstallRecord(record);
|
||||
if (installRecord) {
|
||||
normalized[pluginId] = installRecord;
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function restoreInstallRecordMap(
|
||||
records: Readonly<Record<string, InstalledPluginInstallRecordInfo>> | undefined,
|
||||
): Record<string, PluginInstallRecord> {
|
||||
const restored: Record<string, PluginInstallRecord> = {};
|
||||
for (const [pluginId, record] of Object.entries(records ?? {}).toSorted(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
)) {
|
||||
const installRecord = restoreInstallRecord(record);
|
||||
if (installRecord) {
|
||||
restored[pluginId] = installRecord;
|
||||
}
|
||||
}
|
||||
return restored;
|
||||
}
|
||||
|
||||
export function extractPluginInstallRecordsFromInstalledPluginIndex(
|
||||
index: InstalledPluginIndex | null | undefined,
|
||||
): Record<string, PluginInstallRecord> {
|
||||
if (index && Object.prototype.hasOwnProperty.call(index, "installRecords")) {
|
||||
return restoreInstallRecordMap(index.installRecords);
|
||||
}
|
||||
const records: Record<string, PluginInstallRecord> = {};
|
||||
for (const plugin of index?.plugins ?? []) {
|
||||
const record = restoreInstallRecord(plugin.installRecord);
|
||||
if (record) {
|
||||
records[plugin.pluginId] = record;
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
71
src/plugins/installed-plugin-index-invalidation.ts
Normal file
71
src/plugins/installed-plugin-index-invalidation.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { hashJson } from "./installed-plugin-index-hash.js";
|
||||
import type {
|
||||
InstalledPluginIndex,
|
||||
InstalledPluginIndexRefreshReason,
|
||||
} from "./installed-plugin-index-types.js";
|
||||
|
||||
export function diffInstalledPluginIndexInvalidationReasons(
|
||||
previous: InstalledPluginIndex,
|
||||
current: InstalledPluginIndex,
|
||||
): readonly InstalledPluginIndexRefreshReason[] {
|
||||
const reasons = new Set<InstalledPluginIndexRefreshReason>();
|
||||
if (previous.version !== current.version) {
|
||||
reasons.add("missing");
|
||||
}
|
||||
if (previous.hostContractVersion !== current.hostContractVersion) {
|
||||
reasons.add("host-contract-changed");
|
||||
}
|
||||
if (previous.compatRegistryVersion !== current.compatRegistryVersion) {
|
||||
reasons.add("compat-registry-changed");
|
||||
}
|
||||
if (previous.migrationVersion !== current.migrationVersion) {
|
||||
reasons.add("migration");
|
||||
}
|
||||
if (previous.policyHash !== current.policyHash) {
|
||||
reasons.add("policy-changed");
|
||||
}
|
||||
if (hashJson(previous.installRecords ?? {}) !== hashJson(current.installRecords ?? {})) {
|
||||
reasons.add("source-changed");
|
||||
}
|
||||
|
||||
const previousByPluginId = new Map(previous.plugins.map((plugin) => [plugin.pluginId, plugin]));
|
||||
const currentByPluginId = new Map(current.plugins.map((plugin) => [plugin.pluginId, plugin]));
|
||||
for (const [pluginId, previousPlugin] of previousByPluginId) {
|
||||
const currentPlugin = currentByPluginId.get(pluginId);
|
||||
if (!currentPlugin) {
|
||||
reasons.add("source-changed");
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
previousPlugin.rootDir !== currentPlugin.rootDir ||
|
||||
previousPlugin.manifestPath !== currentPlugin.manifestPath ||
|
||||
previousPlugin.installRecordHash !== currentPlugin.installRecordHash
|
||||
) {
|
||||
reasons.add("source-changed");
|
||||
}
|
||||
if (previousPlugin.enabled !== currentPlugin.enabled) {
|
||||
reasons.add("policy-changed");
|
||||
}
|
||||
if (previousPlugin.manifestHash !== currentPlugin.manifestHash) {
|
||||
reasons.add("stale-manifest");
|
||||
}
|
||||
if (
|
||||
previousPlugin.packageVersion !== currentPlugin.packageVersion ||
|
||||
previousPlugin.packageJson?.path !== currentPlugin.packageJson?.path ||
|
||||
previousPlugin.packageJson?.hash !== currentPlugin.packageJson?.hash
|
||||
) {
|
||||
reasons.add("stale-package");
|
||||
}
|
||||
}
|
||||
for (const pluginId of currentByPluginId.keys()) {
|
||||
if (!previousByPluginId.has(pluginId)) {
|
||||
const currentPlugin = currentByPluginId.get(pluginId);
|
||||
if (currentPlugin?.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
reasons.add("source-changed");
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(reasons).toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
51
src/plugins/installed-plugin-index-policy.ts
Normal file
51
src/plugins/installed-plugin-index-policy.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
import { listPluginCompatRecords } from "./compat/registry.js";
|
||||
import { normalizePluginsConfig } from "./config-state.js";
|
||||
import { hashJson } from "./installed-plugin-index-hash.js";
|
||||
|
||||
export function resolveCompatRegistryVersion(): string {
|
||||
return hashJson(
|
||||
listPluginCompatRecords().map((record) => ({
|
||||
code: record.code,
|
||||
status: record.status,
|
||||
deprecated: record.deprecated,
|
||||
warningStarts: record.warningStarts,
|
||||
removeAfter: record.removeAfter,
|
||||
replacement: record.replacement,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveInstalledPluginIndexPolicyHash(config: OpenClawConfig | undefined): string {
|
||||
const normalized = normalizePluginsConfig(config?.plugins);
|
||||
const channelPolicy: Record<string, boolean> = {};
|
||||
const channels = config?.channels;
|
||||
if (channels && typeof channels === "object" && !Array.isArray(channels)) {
|
||||
for (const [channelId, value] of Object.entries(channels)) {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
const enabled = (value as Record<string, unknown>).enabled;
|
||||
if (typeof enabled === "boolean") {
|
||||
channelPolicy[channelId] = enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return hashJson({
|
||||
plugins: {
|
||||
enabled: normalized.enabled,
|
||||
allow: normalized.allow,
|
||||
deny: normalized.deny,
|
||||
slots: normalized.slots,
|
||||
entries: Object.fromEntries(
|
||||
Object.entries(normalized.entries)
|
||||
.flatMap(([pluginId, entry]) =>
|
||||
typeof entry.enabled === "boolean" ? [[pluginId, entry.enabled] as const] : [],
|
||||
)
|
||||
.toSorted(([left], [right]) => left.localeCompare(right)),
|
||||
),
|
||||
},
|
||||
channels: Object.fromEntries(
|
||||
Object.entries(channelPolicy).toSorted(([left], [right]) => left.localeCompare(right)),
|
||||
),
|
||||
});
|
||||
}
|
||||
291
src/plugins/installed-plugin-index-record-builder.ts
Normal file
291
src/plugins/installed-plugin-index-record-builder.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
import type { PluginCompatCode } from "./compat/registry.js";
|
||||
import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js";
|
||||
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 type {
|
||||
InstalledPluginIndexRecord,
|
||||
InstalledPluginInstallRecordInfo,
|
||||
InstalledPluginPackageChannelInfo,
|
||||
InstalledPluginStartupInfo,
|
||||
} from "./installed-plugin-index-types.js";
|
||||
import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js";
|
||||
import type { PluginDiagnostic } from "./manifest-types.js";
|
||||
import type { PluginPackageChannel } from "./manifest.js";
|
||||
import { safeRealpathSync } from "./path-safety.js";
|
||||
import { hasKind } from "./slots.js";
|
||||
|
||||
function sortUnique(values: readonly string[] | undefined): readonly string[] {
|
||||
if (!values || values.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))).toSorted(
|
||||
(left, right) => left.localeCompare(right),
|
||||
);
|
||||
}
|
||||
|
||||
function hasRuntimeContractSurface(record: PluginManifestRecord): boolean {
|
||||
const providers = record.providers ?? [];
|
||||
const cliBackends = record.cliBackends ?? [];
|
||||
return Boolean(
|
||||
providers.length > 0 ||
|
||||
cliBackends.length > 0 ||
|
||||
record.contracts?.speechProviders?.length ||
|
||||
record.contracts?.mediaUnderstandingProviders?.length ||
|
||||
record.contracts?.documentExtractors?.length ||
|
||||
record.contracts?.imageGenerationProviders?.length ||
|
||||
record.contracts?.videoGenerationProviders?.length ||
|
||||
record.contracts?.musicGenerationProviders?.length ||
|
||||
record.contracts?.webContentExtractors?.length ||
|
||||
record.contracts?.webFetchProviders?.length ||
|
||||
record.contracts?.webSearchProviders?.length ||
|
||||
record.contracts?.memoryEmbeddingProviders?.length ||
|
||||
hasKind(record.kind, "memory"),
|
||||
);
|
||||
}
|
||||
|
||||
function buildStartupInfo(record: PluginManifestRecord): InstalledPluginStartupInfo {
|
||||
const channels = record.channels ?? [];
|
||||
return {
|
||||
sidecar: channels.length === 0 && !hasRuntimeContractSurface(record),
|
||||
memory: hasKind(record.kind, "memory"),
|
||||
deferConfiguredChannelFullLoadUntilAfterListen:
|
||||
record.startupDeferConfiguredChannelFullLoadUntilAfterListen === true,
|
||||
agentHarnesses: sortUnique([
|
||||
...(record.activation?.onAgentHarnesses ?? []),
|
||||
...(record.cliBackends ?? []),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function collectCompatCodes(record: PluginManifestRecord): readonly PluginCompatCode[] {
|
||||
const codes: PluginCompatCode[] = [];
|
||||
if (record.providerAuthEnvVars && Object.keys(record.providerAuthEnvVars).length > 0) {
|
||||
codes.push("provider-auth-env-vars");
|
||||
}
|
||||
if (record.channelEnvVars && Object.keys(record.channelEnvVars).length > 0) {
|
||||
codes.push("channel-env-vars");
|
||||
}
|
||||
if (record.activation?.onProviders?.length) {
|
||||
codes.push("activation-provider-hint");
|
||||
}
|
||||
if (record.activation?.onAgentHarnesses?.length) {
|
||||
codes.push("activation-agent-harness-hint");
|
||||
}
|
||||
if (record.activation?.onChannels?.length) {
|
||||
codes.push("activation-channel-hint");
|
||||
}
|
||||
if (record.activation?.onCommands?.length) {
|
||||
codes.push("activation-command-hint");
|
||||
}
|
||||
if (record.activation?.onRoutes?.length) {
|
||||
codes.push("activation-route-hint");
|
||||
}
|
||||
if (record.activation?.onCapabilities?.length) {
|
||||
codes.push("activation-capability-hint");
|
||||
}
|
||||
return sortUnique(codes) as readonly PluginCompatCode[];
|
||||
}
|
||||
|
||||
function resolvePackageJsonPath(candidate: PluginCandidate | undefined): string | undefined {
|
||||
if (!candidate?.packageDir) {
|
||||
return undefined;
|
||||
}
|
||||
const packageDir = safeRealpathSync(candidate.packageDir) ?? path.resolve(candidate.packageDir);
|
||||
const packageJsonPath = path.join(packageDir, "package.json");
|
||||
return fs.existsSync(packageJsonPath) ? packageJsonPath : undefined;
|
||||
}
|
||||
|
||||
function resolvePackageJsonRelativePath(rootDir: string, packageJsonPath: string): string {
|
||||
const resolvedRootDir = safeRealpathSync(rootDir) ?? path.resolve(rootDir);
|
||||
const relativePath = path.relative(resolvedRootDir, packageJsonPath) || "package.json";
|
||||
return relativePath.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function resolvePackageJsonRecord(params: {
|
||||
candidate: PluginCandidate | undefined;
|
||||
packageJsonPath: string | undefined;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
pluginId: string;
|
||||
}): InstalledPluginIndexRecord["packageJson"] | undefined {
|
||||
if (!params.candidate?.packageDir || !params.packageJsonPath) {
|
||||
return undefined;
|
||||
}
|
||||
const hash = safeHashFile({
|
||||
filePath: params.packageJsonPath,
|
||||
pluginId: params.pluginId,
|
||||
diagnostics: params.diagnostics,
|
||||
required: false,
|
||||
});
|
||||
if (!hash) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
path: resolvePackageJsonRelativePath(params.candidate.rootDir, params.packageJsonPath),
|
||||
hash,
|
||||
};
|
||||
}
|
||||
|
||||
function describePackageInstallSource(
|
||||
candidate: PluginCandidate | undefined,
|
||||
): PluginInstallSourceInfo | undefined {
|
||||
const install = candidate?.packageManifest?.install;
|
||||
if (!install) {
|
||||
return undefined;
|
||||
}
|
||||
return describePluginInstallSource(install, {
|
||||
expectedPackageName: candidate?.packageName,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeStringField(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value.trim();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
function normalizeStringListField(value: unknown): readonly string[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value
|
||||
.flatMap((entry) => {
|
||||
const normalizedEntry = normalizeStringField(entry);
|
||||
return normalizedEntry ? [normalizedEntry] : [];
|
||||
})
|
||||
.filter((entry, index, all) => all.indexOf(entry) === index);
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function normalizePackageChannel(
|
||||
channel: PluginPackageChannel | undefined,
|
||||
): InstalledPluginPackageChannelInfo | undefined {
|
||||
const id = normalizeStringField(channel?.id);
|
||||
if (!id) {
|
||||
return undefined;
|
||||
}
|
||||
const label = normalizeStringField(channel?.label);
|
||||
const blurb = normalizeStringField(channel?.blurb);
|
||||
const preferOver = normalizeStringListField(channel?.preferOver);
|
||||
const commands =
|
||||
channel?.commands &&
|
||||
typeof channel.commands === "object" &&
|
||||
!Array.isArray(channel.commands) &&
|
||||
(typeof channel.commands.nativeCommandsAutoEnabled === "boolean" ||
|
||||
typeof channel.commands.nativeSkillsAutoEnabled === "boolean")
|
||||
? {
|
||||
...(typeof channel.commands.nativeCommandsAutoEnabled === "boolean"
|
||||
? { nativeCommandsAutoEnabled: channel.commands.nativeCommandsAutoEnabled }
|
||||
: {}),
|
||||
...(typeof channel.commands.nativeSkillsAutoEnabled === "boolean"
|
||||
? { nativeSkillsAutoEnabled: channel.commands.nativeSkillsAutoEnabled }
|
||||
: {}),
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
id,
|
||||
...(label ? { label } : {}),
|
||||
...(blurb ? { blurb } : {}),
|
||||
...(preferOver ? { preferOver } : {}),
|
||||
...(commands ? { commands } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildCandidateLookup(
|
||||
candidates: readonly PluginCandidate[],
|
||||
): Map<string, PluginCandidate> {
|
||||
const byRootDir = new Map<string, PluginCandidate>();
|
||||
for (const candidate of candidates) {
|
||||
byRootDir.set(candidate.rootDir, candidate);
|
||||
}
|
||||
return byRootDir;
|
||||
}
|
||||
|
||||
export function buildInstalledPluginIndexRecords(params: {
|
||||
candidates: readonly PluginCandidate[];
|
||||
registry: PluginManifestRegistry;
|
||||
config?: OpenClawConfig;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
installRecords: Record<string, InstalledPluginInstallRecordInfo>;
|
||||
}): InstalledPluginIndexRecord[] {
|
||||
const candidateByRootDir = buildCandidateLookup(params.candidates);
|
||||
const normalizedConfig = normalizePluginsConfig(params.config?.plugins);
|
||||
return params.registry.plugins.map((record): InstalledPluginIndexRecord => {
|
||||
const candidate = candidateByRootDir.get(record.rootDir);
|
||||
const packageJsonPath = resolvePackageJsonPath(candidate);
|
||||
const installRecord = params.installRecords[record.id];
|
||||
const packageInstall = describePackageInstallSource(candidate);
|
||||
const packageChannel = normalizePackageChannel(candidate?.packageManifest?.channel);
|
||||
const manifestHash =
|
||||
safeHashFile({
|
||||
filePath: record.manifestPath,
|
||||
pluginId: record.id,
|
||||
diagnostics: params.diagnostics,
|
||||
required: true,
|
||||
}) ?? "";
|
||||
const packageJson = resolvePackageJsonRecord({
|
||||
candidate,
|
||||
packageJsonPath,
|
||||
diagnostics: params.diagnostics,
|
||||
pluginId: record.id,
|
||||
});
|
||||
const enabled = resolveEffectiveEnableState({
|
||||
id: record.id,
|
||||
origin: record.origin,
|
||||
config: normalizedConfig,
|
||||
rootConfig: params.config,
|
||||
enabledByDefault: record.enabledByDefault,
|
||||
}).enabled;
|
||||
const indexRecord: InstalledPluginIndexRecord = {
|
||||
pluginId: record.id,
|
||||
manifestPath: record.manifestPath,
|
||||
manifestHash,
|
||||
source: record.source,
|
||||
rootDir: record.rootDir,
|
||||
origin: record.origin,
|
||||
enabled,
|
||||
startup: buildStartupInfo(record),
|
||||
compat: collectCompatCodes(record),
|
||||
};
|
||||
if (record.format && record.format !== "openclaw") {
|
||||
indexRecord.format = record.format;
|
||||
}
|
||||
if (record.bundleFormat) {
|
||||
indexRecord.bundleFormat = record.bundleFormat;
|
||||
}
|
||||
if (record.enabledByDefault === true) {
|
||||
indexRecord.enabledByDefault = true;
|
||||
}
|
||||
if (record.syntheticAuthRefs && record.syntheticAuthRefs.length > 0) {
|
||||
indexRecord.syntheticAuthRefs = record.syntheticAuthRefs;
|
||||
}
|
||||
if (record.setupSource) {
|
||||
indexRecord.setupSource = record.setupSource;
|
||||
}
|
||||
if (candidate?.packageName) {
|
||||
indexRecord.packageName = candidate.packageName;
|
||||
}
|
||||
if (candidate?.packageVersion) {
|
||||
indexRecord.packageVersion = candidate.packageVersion;
|
||||
}
|
||||
if (installRecord) {
|
||||
indexRecord.installRecordHash = hashJson(installRecord);
|
||||
}
|
||||
if (packageInstall) {
|
||||
indexRecord.packageInstall = packageInstall;
|
||||
}
|
||||
if (packageChannel) {
|
||||
indexRecord.packageChannel = packageChannel;
|
||||
}
|
||||
if (packageJson) {
|
||||
indexRecord.packageJson = packageJson;
|
||||
}
|
||||
return indexRecord;
|
||||
});
|
||||
}
|
||||
44
src/plugins/installed-plugin-index-registry.ts
Normal file
44
src/plugins/installed-plugin-index-registry.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { normalizePluginsConfig } from "./config-state.js";
|
||||
import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js";
|
||||
import type { LoadInstalledPluginIndexParams } from "./installed-plugin-index-types.js";
|
||||
import { loadPluginManifestRegistry, type PluginManifestRegistry } from "./manifest-registry.js";
|
||||
|
||||
export function resolveInstalledPluginIndexRegistry(params: LoadInstalledPluginIndexParams): {
|
||||
registry: PluginManifestRegistry;
|
||||
candidates: readonly PluginCandidate[];
|
||||
} {
|
||||
if (params.candidates) {
|
||||
return {
|
||||
candidates: params.candidates,
|
||||
registry: loadPluginManifestRegistry({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
cache: false,
|
||||
env: params.env,
|
||||
candidates: params.candidates,
|
||||
diagnostics: params.diagnostics,
|
||||
installRecords: params.installRecords,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = normalizePluginsConfig(params.config?.plugins);
|
||||
const discovery = discoverOpenClawPlugins({
|
||||
workspaceDir: params.workspaceDir,
|
||||
extraPaths: normalized.loadPaths,
|
||||
cache: params.cache,
|
||||
env: params.env,
|
||||
});
|
||||
return {
|
||||
candidates: discovery.candidates,
|
||||
registry: loadPluginManifestRegistry({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
cache: false,
|
||||
env: params.env,
|
||||
candidates: discovery.candidates,
|
||||
diagnostics: discovery.diagnostics,
|
||||
installRecords: params.installRecords,
|
||||
}),
|
||||
};
|
||||
}
|
||||
126
src/plugins/installed-plugin-index-types.ts
Normal file
126
src/plugins/installed-plugin-index-types.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
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 { PluginManifestRecord } from "./manifest-registry.js";
|
||||
import type { PluginDiagnostic } from "./manifest-types.js";
|
||||
import type { PluginPackageChannel } from "./manifest.js";
|
||||
|
||||
export const INSTALLED_PLUGIN_INDEX_VERSION = 1;
|
||||
export const INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION = 1;
|
||||
export const INSTALLED_PLUGIN_INDEX_WARNING =
|
||||
"DO NOT EDIT. This file is generated by OpenClaw from plugin manifests, install records, and config policy. Use `openclaw plugins registry --refresh`, `openclaw plugins install/update/uninstall`, or `openclaw plugins enable/disable` instead.";
|
||||
|
||||
export type InstalledPluginIndexRefreshReason =
|
||||
| "missing"
|
||||
| "stale-manifest"
|
||||
| "stale-package"
|
||||
| "source-changed"
|
||||
| "policy-changed"
|
||||
| "migration"
|
||||
| "host-contract-changed"
|
||||
| "compat-registry-changed"
|
||||
| "manual";
|
||||
|
||||
export type InstalledPluginStartupInfo = {
|
||||
sidecar: boolean;
|
||||
memory: boolean;
|
||||
deferConfiguredChannelFullLoadUntilAfterListen: boolean;
|
||||
agentHarnesses: 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 InstalledPluginPackageChannelInfo = Pick<
|
||||
PluginPackageChannel,
|
||||
"id" | "label" | "blurb" | "preferOver" | "commands"
|
||||
>;
|
||||
|
||||
export type InstalledPluginIndexRecord = {
|
||||
pluginId: string;
|
||||
packageName?: string;
|
||||
packageVersion?: string;
|
||||
/**
|
||||
* Legacy embedded install record accepted when reading earlier index files.
|
||||
* New index writes keep install records in InstalledPluginIndex.installRecords.
|
||||
*/
|
||||
installRecord?: InstalledPluginInstallRecordInfo;
|
||||
/** Hash of the top-level installRecords entry; used to detect source-changed invalidation. */
|
||||
installRecordHash?: string;
|
||||
/**
|
||||
* Package-authored openclaw.install metadata. This describes catalog/package
|
||||
* install intent and must not be treated as the durable install record.
|
||||
*/
|
||||
packageInstall?: PluginInstallSourceInfo;
|
||||
packageChannel?: InstalledPluginPackageChannelInfo;
|
||||
manifestPath: string;
|
||||
manifestHash: string;
|
||||
format?: PluginManifestRecord["format"];
|
||||
bundleFormat?: PluginManifestRecord["bundleFormat"];
|
||||
source?: string;
|
||||
setupSource?: string;
|
||||
packageJson?: {
|
||||
path: string;
|
||||
hash: string;
|
||||
};
|
||||
rootDir: string;
|
||||
origin: PluginManifestRecord["origin"];
|
||||
enabled: boolean;
|
||||
enabledByDefault?: boolean;
|
||||
syntheticAuthRefs?: readonly string[];
|
||||
startup: InstalledPluginStartupInfo;
|
||||
compat: readonly PluginCompatCode[];
|
||||
};
|
||||
|
||||
export type InstalledPluginIndex = {
|
||||
version: typeof INSTALLED_PLUGIN_INDEX_VERSION;
|
||||
warning?: string;
|
||||
hostContractVersion: string;
|
||||
compatRegistryVersion: string;
|
||||
migrationVersion: typeof INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION;
|
||||
policyHash: string;
|
||||
generatedAtMs: number;
|
||||
refreshReason?: InstalledPluginIndexRefreshReason;
|
||||
installRecords: Readonly<Record<string, InstalledPluginInstallRecordInfo>>;
|
||||
plugins: readonly InstalledPluginIndexRecord[];
|
||||
diagnostics: readonly PluginDiagnostic[];
|
||||
};
|
||||
|
||||
export type LoadInstalledPluginIndexParams = {
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
pluginIndexFilePath?: string;
|
||||
installRecords?: Record<string, PluginInstallRecord>;
|
||||
cache?: boolean;
|
||||
candidates?: PluginCandidate[];
|
||||
diagnostics?: PluginDiagnostic[];
|
||||
now?: () => Date;
|
||||
};
|
||||
|
||||
export type RefreshInstalledPluginIndexParams = LoadInstalledPluginIndexParams & {
|
||||
reason: InstalledPluginIndexRefreshReason;
|
||||
};
|
||||
@@ -1,637 +1,58 @@
|
||||
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 { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js";
|
||||
import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js";
|
||||
import { normalizeInstallRecordMap } from "./installed-plugin-index-install-records.js";
|
||||
import {
|
||||
describePluginInstallSource,
|
||||
type PluginInstallSourceInfo,
|
||||
} from "./install-source-info.js";
|
||||
resolveCompatRegistryVersion,
|
||||
resolveInstalledPluginIndexPolicyHash,
|
||||
} from "./installed-plugin-index-policy.js";
|
||||
import { buildInstalledPluginIndexRecords } from "./installed-plugin-index-record-builder.js";
|
||||
import { resolveInstalledPluginIndexRegistry } from "./installed-plugin-index-registry.js";
|
||||
import {
|
||||
loadPluginManifestRegistry,
|
||||
type PluginManifestRecord,
|
||||
type PluginManifestRegistry,
|
||||
} from "./manifest-registry.js";
|
||||
import type { PluginDiagnostic } from "./manifest-types.js";
|
||||
import type { PluginPackageChannel } from "./manifest.js";
|
||||
import { safeRealpathSync } from "./path-safety.js";
|
||||
import { hasKind } from "./slots.js";
|
||||
INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION,
|
||||
INSTALLED_PLUGIN_INDEX_VERSION,
|
||||
INSTALLED_PLUGIN_INDEX_WARNING,
|
||||
type InstalledPluginIndex,
|
||||
type InstalledPluginIndexRecord,
|
||||
type InstalledPluginIndexRefreshReason,
|
||||
type LoadInstalledPluginIndexParams,
|
||||
type RefreshInstalledPluginIndexParams,
|
||||
} from "./installed-plugin-index-types.js";
|
||||
|
||||
export const INSTALLED_PLUGIN_INDEX_VERSION = 1;
|
||||
export const INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION = 1;
|
||||
export const INSTALLED_PLUGIN_INDEX_WARNING =
|
||||
"DO NOT EDIT. This file is generated by OpenClaw from plugin manifests, install records, and config policy. Use `openclaw plugins registry --refresh`, `openclaw plugins install/update/uninstall`, or `openclaw plugins enable/disable` instead.";
|
||||
|
||||
export type InstalledPluginIndexRefreshReason =
|
||||
| "missing"
|
||||
| "stale-manifest"
|
||||
| "stale-package"
|
||||
| "source-changed"
|
||||
| "policy-changed"
|
||||
| "migration"
|
||||
| "host-contract-changed"
|
||||
| "compat-registry-changed"
|
||||
| "manual";
|
||||
|
||||
export type InstalledPluginStartupInfo = {
|
||||
sidecar: boolean;
|
||||
memory: boolean;
|
||||
deferConfiguredChannelFullLoadUntilAfterListen: boolean;
|
||||
agentHarnesses: 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 InstalledPluginPackageChannelInfo = Pick<
|
||||
PluginPackageChannel,
|
||||
"id" | "label" | "blurb" | "preferOver" | "commands"
|
||||
>;
|
||||
|
||||
export type InstalledPluginIndexRecord = {
|
||||
pluginId: string;
|
||||
packageName?: string;
|
||||
packageVersion?: string;
|
||||
/**
|
||||
* Legacy embedded install record accepted when reading earlier index files.
|
||||
* New index writes keep install records in InstalledPluginIndex.installRecords.
|
||||
*/
|
||||
installRecord?: InstalledPluginInstallRecordInfo;
|
||||
/** Hash of the top-level installRecords entry; used to detect source-changed invalidation. */
|
||||
installRecordHash?: string;
|
||||
/**
|
||||
* Package-authored openclaw.install metadata. This describes catalog/package
|
||||
* install intent and must not be treated as the durable install record.
|
||||
*/
|
||||
packageInstall?: PluginInstallSourceInfo;
|
||||
packageChannel?: InstalledPluginPackageChannelInfo;
|
||||
manifestPath: string;
|
||||
manifestHash: string;
|
||||
format?: PluginManifestRecord["format"];
|
||||
bundleFormat?: PluginManifestRecord["bundleFormat"];
|
||||
source?: string;
|
||||
setupSource?: string;
|
||||
packageJson?: {
|
||||
path: string;
|
||||
hash: string;
|
||||
};
|
||||
rootDir: string;
|
||||
origin: PluginManifestRecord["origin"];
|
||||
enabled: boolean;
|
||||
enabledByDefault?: boolean;
|
||||
syntheticAuthRefs?: readonly string[];
|
||||
startup: InstalledPluginStartupInfo;
|
||||
compat: readonly PluginCompatCode[];
|
||||
};
|
||||
|
||||
export type InstalledPluginIndex = {
|
||||
version: typeof INSTALLED_PLUGIN_INDEX_VERSION;
|
||||
warning?: string;
|
||||
hostContractVersion: string;
|
||||
compatRegistryVersion: string;
|
||||
migrationVersion: typeof INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION;
|
||||
policyHash: string;
|
||||
generatedAtMs: number;
|
||||
refreshReason?: InstalledPluginIndexRefreshReason;
|
||||
installRecords: Readonly<Record<string, InstalledPluginInstallRecordInfo>>;
|
||||
plugins: readonly InstalledPluginIndexRecord[];
|
||||
diagnostics: readonly PluginDiagnostic[];
|
||||
};
|
||||
|
||||
export type LoadInstalledPluginIndexParams = {
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
pluginIndexFilePath?: string;
|
||||
installRecords?: Record<string, PluginInstallRecord>;
|
||||
cache?: boolean;
|
||||
candidates?: PluginCandidate[];
|
||||
diagnostics?: PluginDiagnostic[];
|
||||
now?: () => Date;
|
||||
};
|
||||
|
||||
export type RefreshInstalledPluginIndexParams = LoadInstalledPluginIndexParams & {
|
||||
reason: InstalledPluginIndexRefreshReason;
|
||||
};
|
||||
|
||||
function hashString(value: string): string {
|
||||
return crypto.createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function hashJson(value: unknown): string {
|
||||
return hashString(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function safeHashFile(params: {
|
||||
filePath: string;
|
||||
pluginId?: string;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
required: boolean;
|
||||
}): string | undefined {
|
||||
try {
|
||||
return crypto.createHash("sha256").update(fs.readFileSync(params.filePath)).digest("hex");
|
||||
} catch (err) {
|
||||
if (params.required) {
|
||||
params.diagnostics.push({
|
||||
level: "warn",
|
||||
...(params.pluginId ? { pluginId: params.pluginId } : {}),
|
||||
source: params.filePath,
|
||||
message: `installed plugin index could not hash ${params.filePath}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function sortUnique(values: readonly string[] | undefined): readonly string[] {
|
||||
if (!values || values.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))).toSorted(
|
||||
(left, right) => left.localeCompare(right),
|
||||
);
|
||||
}
|
||||
|
||||
function hasRuntimeContractSurface(record: PluginManifestRecord): boolean {
|
||||
const providers = record.providers ?? [];
|
||||
const cliBackends = record.cliBackends ?? [];
|
||||
return Boolean(
|
||||
providers.length > 0 ||
|
||||
cliBackends.length > 0 ||
|
||||
record.contracts?.speechProviders?.length ||
|
||||
record.contracts?.mediaUnderstandingProviders?.length ||
|
||||
record.contracts?.documentExtractors?.length ||
|
||||
record.contracts?.imageGenerationProviders?.length ||
|
||||
record.contracts?.videoGenerationProviders?.length ||
|
||||
record.contracts?.musicGenerationProviders?.length ||
|
||||
record.contracts?.webContentExtractors?.length ||
|
||||
record.contracts?.webFetchProviders?.length ||
|
||||
record.contracts?.webSearchProviders?.length ||
|
||||
record.contracts?.memoryEmbeddingProviders?.length ||
|
||||
hasKind(record.kind, "memory"),
|
||||
);
|
||||
}
|
||||
|
||||
function buildStartupInfo(record: PluginManifestRecord): InstalledPluginStartupInfo {
|
||||
const channels = record.channels ?? [];
|
||||
return {
|
||||
sidecar: channels.length === 0 && !hasRuntimeContractSurface(record),
|
||||
memory: hasKind(record.kind, "memory"),
|
||||
deferConfiguredChannelFullLoadUntilAfterListen:
|
||||
record.startupDeferConfiguredChannelFullLoadUntilAfterListen === true,
|
||||
agentHarnesses: sortUnique([
|
||||
...(record.activation?.onAgentHarnesses ?? []),
|
||||
...(record.cliBackends ?? []),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function collectCompatCodes(record: PluginManifestRecord): readonly PluginCompatCode[] {
|
||||
const codes: PluginCompatCode[] = [];
|
||||
if (record.providerAuthEnvVars && Object.keys(record.providerAuthEnvVars).length > 0) {
|
||||
codes.push("provider-auth-env-vars");
|
||||
}
|
||||
if (record.channelEnvVars && Object.keys(record.channelEnvVars).length > 0) {
|
||||
codes.push("channel-env-vars");
|
||||
}
|
||||
if (record.activation?.onProviders?.length) {
|
||||
codes.push("activation-provider-hint");
|
||||
}
|
||||
if (record.activation?.onAgentHarnesses?.length) {
|
||||
codes.push("activation-agent-harness-hint");
|
||||
}
|
||||
if (record.activation?.onChannels?.length) {
|
||||
codes.push("activation-channel-hint");
|
||||
}
|
||||
if (record.activation?.onCommands?.length) {
|
||||
codes.push("activation-command-hint");
|
||||
}
|
||||
if (record.activation?.onRoutes?.length) {
|
||||
codes.push("activation-route-hint");
|
||||
}
|
||||
if (record.activation?.onCapabilities?.length) {
|
||||
codes.push("activation-capability-hint");
|
||||
}
|
||||
return sortUnique(codes) as readonly PluginCompatCode[];
|
||||
}
|
||||
|
||||
function resolvePackageJsonPath(candidate: PluginCandidate | undefined): string | undefined {
|
||||
if (!candidate?.packageDir) {
|
||||
return undefined;
|
||||
}
|
||||
const packageDir = safeRealpathSync(candidate.packageDir) ?? path.resolve(candidate.packageDir);
|
||||
const packageJsonPath = path.join(packageDir, "package.json");
|
||||
return fs.existsSync(packageJsonPath) ? packageJsonPath : undefined;
|
||||
}
|
||||
|
||||
function resolvePackageJsonRelativePath(rootDir: string, packageJsonPath: string): string {
|
||||
const resolvedRootDir = safeRealpathSync(rootDir) ?? path.resolve(rootDir);
|
||||
const relativePath = path.relative(resolvedRootDir, packageJsonPath) || "package.json";
|
||||
return relativePath.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function resolvePackageJsonRecord(params: {
|
||||
candidate: PluginCandidate | undefined;
|
||||
packageJsonPath: string | undefined;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
pluginId: string;
|
||||
}): InstalledPluginIndexRecord["packageJson"] | undefined {
|
||||
if (!params.candidate?.packageDir || !params.packageJsonPath) {
|
||||
return undefined;
|
||||
}
|
||||
const hash = safeHashFile({
|
||||
filePath: params.packageJsonPath,
|
||||
pluginId: params.pluginId,
|
||||
diagnostics: params.diagnostics,
|
||||
required: false,
|
||||
});
|
||||
if (!hash) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
path: resolvePackageJsonRelativePath(params.candidate.rootDir, params.packageJsonPath),
|
||||
hash,
|
||||
};
|
||||
}
|
||||
|
||||
function describePackageInstallSource(
|
||||
candidate: PluginCandidate | undefined,
|
||||
): PluginInstallSourceInfo | undefined {
|
||||
const install = candidate?.packageManifest?.install;
|
||||
if (!install) {
|
||||
return undefined;
|
||||
}
|
||||
return describePluginInstallSource(install, {
|
||||
expectedPackageName: candidate?.packageName,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeStringField(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value.trim();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
function normalizeStringListField(value: unknown): readonly string[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value
|
||||
.flatMap((entry) => {
|
||||
const normalizedEntry = normalizeStringField(entry);
|
||||
return normalizedEntry ? [normalizedEntry] : [];
|
||||
})
|
||||
.filter((entry, index, all) => all.indexOf(entry) === index);
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function normalizePackageChannel(
|
||||
channel: PluginPackageChannel | undefined,
|
||||
): InstalledPluginPackageChannelInfo | undefined {
|
||||
const id = normalizeStringField(channel?.id);
|
||||
if (!id) {
|
||||
return undefined;
|
||||
}
|
||||
const label = normalizeStringField(channel?.label);
|
||||
const blurb = normalizeStringField(channel?.blurb);
|
||||
const preferOver = normalizeStringListField(channel?.preferOver);
|
||||
const commands =
|
||||
channel?.commands &&
|
||||
typeof channel.commands === "object" &&
|
||||
!Array.isArray(channel.commands) &&
|
||||
(typeof channel.commands.nativeCommandsAutoEnabled === "boolean" ||
|
||||
typeof channel.commands.nativeSkillsAutoEnabled === "boolean")
|
||||
? {
|
||||
...(typeof channel.commands.nativeCommandsAutoEnabled === "boolean"
|
||||
? { nativeCommandsAutoEnabled: channel.commands.nativeCommandsAutoEnabled }
|
||||
: {}),
|
||||
...(typeof channel.commands.nativeSkillsAutoEnabled === "boolean"
|
||||
? { nativeSkillsAutoEnabled: channel.commands.nativeSkillsAutoEnabled }
|
||||
: {}),
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
id,
|
||||
...(label ? { label } : {}),
|
||||
...(blurb ? { blurb } : {}),
|
||||
...(preferOver ? { preferOver } : {}),
|
||||
...(commands ? { commands } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
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 restoreInstallRecord(
|
||||
record: InstalledPluginInstallRecordInfo | undefined,
|
||||
): PluginInstallRecord | undefined {
|
||||
if (!record?.source) {
|
||||
return undefined;
|
||||
}
|
||||
return structuredClone(record) as PluginInstallRecord;
|
||||
}
|
||||
|
||||
function normalizeInstallRecordMap(
|
||||
records: Record<string, PluginInstallRecord> | undefined,
|
||||
): Record<string, InstalledPluginInstallRecordInfo> {
|
||||
const normalized: Record<string, InstalledPluginInstallRecordInfo> = {};
|
||||
for (const [pluginId, record] of Object.entries(records ?? {}).toSorted(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
)) {
|
||||
const installRecord = normalizeInstallRecord(record);
|
||||
if (installRecord) {
|
||||
normalized[pluginId] = installRecord;
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function restoreInstallRecordMap(
|
||||
records: Readonly<Record<string, InstalledPluginInstallRecordInfo>> | undefined,
|
||||
): Record<string, PluginInstallRecord> {
|
||||
const restored: Record<string, PluginInstallRecord> = {};
|
||||
for (const [pluginId, record] of Object.entries(records ?? {}).toSorted(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
)) {
|
||||
const installRecord = restoreInstallRecord(record);
|
||||
if (installRecord) {
|
||||
restored[pluginId] = installRecord;
|
||||
}
|
||||
}
|
||||
return restored;
|
||||
}
|
||||
|
||||
export function extractPluginInstallRecordsFromInstalledPluginIndex(
|
||||
index: InstalledPluginIndex | null | undefined,
|
||||
): Record<string, PluginInstallRecord> {
|
||||
if (index && Object.prototype.hasOwnProperty.call(index, "installRecords")) {
|
||||
return restoreInstallRecordMap(index.installRecords);
|
||||
}
|
||||
const records: Record<string, PluginInstallRecord> = {};
|
||||
for (const plugin of index?.plugins ?? []) {
|
||||
const record = restoreInstallRecord(plugin.installRecord);
|
||||
if (record) {
|
||||
records[plugin.pluginId] = record;
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
function buildCandidateLookup(
|
||||
candidates: readonly PluginCandidate[],
|
||||
): Map<string, PluginCandidate> {
|
||||
const byRootDir = new Map<string, PluginCandidate>();
|
||||
for (const candidate of candidates) {
|
||||
byRootDir.set(candidate.rootDir, candidate);
|
||||
}
|
||||
return byRootDir;
|
||||
}
|
||||
|
||||
function resolveCompatRegistryVersion(): string {
|
||||
return hashJson(
|
||||
listPluginCompatRecords().map((record) => ({
|
||||
code: record.code,
|
||||
status: record.status,
|
||||
deprecated: record.deprecated,
|
||||
warningStarts: record.warningStarts,
|
||||
removeAfter: record.removeAfter,
|
||||
replacement: record.replacement,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveInstalledPluginIndexPolicyHash(config: OpenClawConfig | undefined): string {
|
||||
const normalized = normalizePluginsConfig(config?.plugins);
|
||||
const channelPolicy: Record<string, boolean> = {};
|
||||
const channels = config?.channels;
|
||||
if (channels && typeof channels === "object" && !Array.isArray(channels)) {
|
||||
for (const [channelId, value] of Object.entries(channels)) {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
const enabled = (value as Record<string, unknown>).enabled;
|
||||
if (typeof enabled === "boolean") {
|
||||
channelPolicy[channelId] = enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return hashJson({
|
||||
plugins: {
|
||||
enabled: normalized.enabled,
|
||||
allow: normalized.allow,
|
||||
deny: normalized.deny,
|
||||
slots: normalized.slots,
|
||||
entries: Object.fromEntries(
|
||||
Object.entries(normalized.entries)
|
||||
.flatMap(([pluginId, entry]) =>
|
||||
typeof entry.enabled === "boolean" ? [[pluginId, entry.enabled] as const] : [],
|
||||
)
|
||||
.toSorted(([left], [right]) => left.localeCompare(right)),
|
||||
),
|
||||
},
|
||||
channels: Object.fromEntries(
|
||||
Object.entries(channelPolicy).toSorted(([left], [right]) => left.localeCompare(right)),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function resolveRegistry(params: LoadInstalledPluginIndexParams): {
|
||||
registry: PluginManifestRegistry;
|
||||
candidates: readonly PluginCandidate[];
|
||||
} {
|
||||
if (params.candidates) {
|
||||
return {
|
||||
candidates: params.candidates,
|
||||
registry: loadPluginManifestRegistry({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
cache: false,
|
||||
env: params.env,
|
||||
candidates: params.candidates,
|
||||
diagnostics: params.diagnostics,
|
||||
installRecords: params.installRecords,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = normalizePluginsConfig(params.config?.plugins);
|
||||
const discovery = discoverOpenClawPlugins({
|
||||
workspaceDir: params.workspaceDir,
|
||||
extraPaths: normalized.loadPaths,
|
||||
cache: params.cache,
|
||||
env: params.env,
|
||||
});
|
||||
return {
|
||||
candidates: discovery.candidates,
|
||||
registry: loadPluginManifestRegistry({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
cache: false,
|
||||
env: params.env,
|
||||
candidates: discovery.candidates,
|
||||
diagnostics: discovery.diagnostics,
|
||||
installRecords: params.installRecords,
|
||||
}),
|
||||
};
|
||||
}
|
||||
export {
|
||||
INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION,
|
||||
INSTALLED_PLUGIN_INDEX_VERSION,
|
||||
INSTALLED_PLUGIN_INDEX_WARNING,
|
||||
} from "./installed-plugin-index-types.js";
|
||||
export type {
|
||||
InstalledPluginIndex,
|
||||
InstalledPluginIndexRecord,
|
||||
InstalledPluginIndexRefreshReason,
|
||||
InstalledPluginInstallRecordInfo,
|
||||
InstalledPluginPackageChannelInfo,
|
||||
InstalledPluginStartupInfo,
|
||||
LoadInstalledPluginIndexParams,
|
||||
RefreshInstalledPluginIndexParams,
|
||||
} from "./installed-plugin-index-types.js";
|
||||
export { extractPluginInstallRecordsFromInstalledPluginIndex } from "./installed-plugin-index-install-records.js";
|
||||
export { diffInstalledPluginIndexInvalidationReasons } from "./installed-plugin-index-invalidation.js";
|
||||
export { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js";
|
||||
|
||||
function buildInstalledPluginIndex(
|
||||
params: LoadInstalledPluginIndexParams & { refreshReason?: InstalledPluginIndexRefreshReason },
|
||||
): InstalledPluginIndex {
|
||||
const env = params.env ?? process.env;
|
||||
const { candidates, registry } = resolveRegistry(params);
|
||||
const candidateByRootDir = buildCandidateLookup(candidates);
|
||||
const normalizedConfig = normalizePluginsConfig(params.config?.plugins);
|
||||
const { candidates, registry } = resolveInstalledPluginIndexRegistry(params);
|
||||
const registryDiagnostics = registry.diagnostics ?? [];
|
||||
const diagnostics: PluginDiagnostic[] = [...registryDiagnostics];
|
||||
const diagnostics = [...registryDiagnostics];
|
||||
const generatedAtMs = (params.now?.() ?? new Date()).getTime();
|
||||
const installRecords = normalizeInstallRecordMap(params.installRecords);
|
||||
const plugins = registry.plugins.map((record): InstalledPluginIndexRecord => {
|
||||
const candidate = candidateByRootDir.get(record.rootDir);
|
||||
const packageJsonPath = resolvePackageJsonPath(candidate);
|
||||
const installRecord = installRecords[record.id];
|
||||
const packageInstall = describePackageInstallSource(candidate);
|
||||
const packageChannel = normalizePackageChannel(candidate?.packageManifest?.channel);
|
||||
const manifestHash =
|
||||
safeHashFile({
|
||||
filePath: record.manifestPath,
|
||||
pluginId: record.id,
|
||||
diagnostics,
|
||||
required: true,
|
||||
}) ?? "";
|
||||
const packageJson = resolvePackageJsonRecord({
|
||||
candidate,
|
||||
packageJsonPath,
|
||||
diagnostics,
|
||||
pluginId: record.id,
|
||||
});
|
||||
const enabled = resolveEffectiveEnableState({
|
||||
id: record.id,
|
||||
origin: record.origin,
|
||||
config: normalizedConfig,
|
||||
rootConfig: params.config,
|
||||
enabledByDefault: record.enabledByDefault,
|
||||
}).enabled;
|
||||
const indexRecord: InstalledPluginIndexRecord = {
|
||||
pluginId: record.id,
|
||||
manifestPath: record.manifestPath,
|
||||
manifestHash,
|
||||
source: record.source,
|
||||
rootDir: record.rootDir,
|
||||
origin: record.origin,
|
||||
enabled,
|
||||
startup: buildStartupInfo(record),
|
||||
compat: collectCompatCodes(record),
|
||||
};
|
||||
if (record.syntheticAuthRefs && record.syntheticAuthRefs.length > 0) {
|
||||
indexRecord.syntheticAuthRefs = record.syntheticAuthRefs;
|
||||
}
|
||||
if (record.format && record.format !== "openclaw") {
|
||||
indexRecord.format = record.format;
|
||||
}
|
||||
if (record.bundleFormat) {
|
||||
indexRecord.bundleFormat = record.bundleFormat;
|
||||
}
|
||||
if (record.enabledByDefault === true) {
|
||||
indexRecord.enabledByDefault = true;
|
||||
}
|
||||
if (record.syntheticAuthRefs && record.syntheticAuthRefs.length > 0) {
|
||||
indexRecord.syntheticAuthRefs = record.syntheticAuthRefs;
|
||||
}
|
||||
if (record.setupSource) {
|
||||
indexRecord.setupSource = record.setupSource;
|
||||
}
|
||||
if (record.syntheticAuthRefs && record.syntheticAuthRefs.length > 0) {
|
||||
indexRecord.syntheticAuthRefs = record.syntheticAuthRefs;
|
||||
}
|
||||
if (candidate?.packageName) {
|
||||
indexRecord.packageName = candidate.packageName;
|
||||
}
|
||||
if (candidate?.packageVersion) {
|
||||
indexRecord.packageVersion = candidate.packageVersion;
|
||||
}
|
||||
if (installRecord) {
|
||||
indexRecord.installRecordHash = hashJson(installRecord);
|
||||
}
|
||||
if (packageInstall) {
|
||||
indexRecord.packageInstall = packageInstall;
|
||||
}
|
||||
if (packageChannel) {
|
||||
indexRecord.packageChannel = packageChannel;
|
||||
}
|
||||
if (packageJson) {
|
||||
indexRecord.packageJson = packageJson;
|
||||
}
|
||||
return indexRecord;
|
||||
const plugins = buildInstalledPluginIndexRecords({
|
||||
candidates,
|
||||
registry,
|
||||
config: params.config,
|
||||
diagnostics,
|
||||
installRecords,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -715,69 +136,3 @@ export function isInstalledPluginEnabled(
|
||||
enabledByDefault: record.enabledByDefault,
|
||||
}).enabled;
|
||||
}
|
||||
|
||||
export function diffInstalledPluginIndexInvalidationReasons(
|
||||
previous: InstalledPluginIndex,
|
||||
current: InstalledPluginIndex,
|
||||
): readonly InstalledPluginIndexRefreshReason[] {
|
||||
const reasons = new Set<InstalledPluginIndexRefreshReason>();
|
||||
if (previous.version !== current.version) {
|
||||
reasons.add("missing");
|
||||
}
|
||||
if (previous.hostContractVersion !== current.hostContractVersion) {
|
||||
reasons.add("host-contract-changed");
|
||||
}
|
||||
if (previous.compatRegistryVersion !== current.compatRegistryVersion) {
|
||||
reasons.add("compat-registry-changed");
|
||||
}
|
||||
if (previous.migrationVersion !== current.migrationVersion) {
|
||||
reasons.add("migration");
|
||||
}
|
||||
if (previous.policyHash !== current.policyHash) {
|
||||
reasons.add("policy-changed");
|
||||
}
|
||||
if (hashJson(previous.installRecords ?? {}) !== hashJson(current.installRecords ?? {})) {
|
||||
reasons.add("source-changed");
|
||||
}
|
||||
|
||||
const previousByPluginId = new Map(previous.plugins.map((plugin) => [plugin.pluginId, plugin]));
|
||||
const currentByPluginId = new Map(current.plugins.map((plugin) => [plugin.pluginId, plugin]));
|
||||
for (const [pluginId, previousPlugin] of previousByPluginId) {
|
||||
const currentPlugin = currentByPluginId.get(pluginId);
|
||||
if (!currentPlugin) {
|
||||
reasons.add("source-changed");
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
previousPlugin.rootDir !== currentPlugin.rootDir ||
|
||||
previousPlugin.manifestPath !== currentPlugin.manifestPath ||
|
||||
previousPlugin.installRecordHash !== currentPlugin.installRecordHash
|
||||
) {
|
||||
reasons.add("source-changed");
|
||||
}
|
||||
if (previousPlugin.enabled !== currentPlugin.enabled) {
|
||||
reasons.add("policy-changed");
|
||||
}
|
||||
if (previousPlugin.manifestHash !== currentPlugin.manifestHash) {
|
||||
reasons.add("stale-manifest");
|
||||
}
|
||||
if (
|
||||
previousPlugin.packageVersion !== currentPlugin.packageVersion ||
|
||||
previousPlugin.packageJson?.path !== currentPlugin.packageJson?.path ||
|
||||
previousPlugin.packageJson?.hash !== currentPlugin.packageJson?.hash
|
||||
) {
|
||||
reasons.add("stale-package");
|
||||
}
|
||||
}
|
||||
for (const pluginId of currentByPluginId.keys()) {
|
||||
if (!previousByPluginId.has(pluginId)) {
|
||||
const currentPlugin = currentByPluginId.get(pluginId);
|
||||
if (currentPlugin?.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
reasons.add("source-changed");
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(reasons).toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user