mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 11:00:43 +00:00
859 lines
28 KiB
TypeScript
859 lines
28 KiB
TypeScript
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 {
|
|
describePluginInstallSource,
|
|
type PluginInstallSourceInfo,
|
|
} from "./install-source-info.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";
|
|
|
|
export const INSTALLED_PLUGIN_INDEX_VERSION = 1;
|
|
export const INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION = 1;
|
|
export const INSTALLED_PLUGIN_CONTRIBUTION_METADATA_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;
|
|
contributionMetadataVersion?: typeof INSTALLED_PLUGIN_CONTRIBUTION_METADATA_VERSION;
|
|
name?: string;
|
|
description?: string;
|
|
manifestVersion?: string;
|
|
legacyPluginIds?: readonly 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"];
|
|
bundleCapabilities?: readonly string[];
|
|
kind?: PluginManifestRecord["kind"];
|
|
channels?: readonly string[];
|
|
providers?: readonly string[];
|
|
cliBackends?: readonly string[];
|
|
setupProviders?: readonly string[];
|
|
channelConfigs?: readonly string[];
|
|
modelCatalogProviders?: readonly string[];
|
|
commandAliases?: readonly string[];
|
|
contractKeys?: readonly string[];
|
|
source?: string;
|
|
setupSource?: string;
|
|
packageJson?: {
|
|
path: string;
|
|
hash: string;
|
|
};
|
|
rootDir: string;
|
|
origin: PluginManifestRecord["origin"];
|
|
enabled: boolean;
|
|
enabledByDefault?: boolean;
|
|
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 ?? []),
|
|
};
|
|
}
|
|
|
|
function collectContractKeys(record: PluginManifestRecord): readonly string[] | undefined {
|
|
const contracts = record.contracts;
|
|
if (!contracts) {
|
|
return undefined;
|
|
}
|
|
return normalizeStringList(
|
|
Object.entries(contracts).flatMap(([key, value]) =>
|
|
Array.isArray(value) && value.length > 0 ? [key] : [],
|
|
),
|
|
);
|
|
}
|
|
|
|
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?.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 normalizeStringList(values: readonly string[] | undefined): readonly string[] | undefined {
|
|
return normalizeStringListField(values);
|
|
}
|
|
|
|
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,
|
|
}),
|
|
};
|
|
}
|
|
|
|
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 registryDiagnostics = registry.diagnostics ?? [];
|
|
const diagnostics: PluginDiagnostic[] = [...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,
|
|
contributionMetadataVersion: INSTALLED_PLUGIN_CONTRIBUTION_METADATA_VERSION,
|
|
manifestPath: record.manifestPath,
|
|
manifestHash,
|
|
source: record.source,
|
|
rootDir: record.rootDir,
|
|
origin: record.origin,
|
|
enabled,
|
|
startup: buildStartupInfo(record),
|
|
compat: collectCompatCodes(record),
|
|
};
|
|
if (record.name) {
|
|
indexRecord.name = record.name;
|
|
}
|
|
if (record.description) {
|
|
indexRecord.description = record.description;
|
|
}
|
|
if (record.version) {
|
|
indexRecord.manifestVersion = record.version;
|
|
}
|
|
const legacyPluginIds = normalizeStringList(record.legacyPluginIds);
|
|
if (legacyPluginIds) {
|
|
indexRecord.legacyPluginIds = legacyPluginIds;
|
|
}
|
|
if (record.format && record.format !== "openclaw") {
|
|
indexRecord.format = record.format;
|
|
}
|
|
if (record.bundleFormat) {
|
|
indexRecord.bundleFormat = record.bundleFormat;
|
|
}
|
|
if (record.bundleCapabilities?.length) {
|
|
indexRecord.bundleCapabilities = normalizeStringList(record.bundleCapabilities);
|
|
}
|
|
if (record.kind) {
|
|
indexRecord.kind = record.kind;
|
|
}
|
|
const channels = normalizeStringList(record.channels);
|
|
if (channels) {
|
|
indexRecord.channels = channels;
|
|
}
|
|
const providers = normalizeStringList(record.providers);
|
|
if (providers) {
|
|
indexRecord.providers = providers;
|
|
}
|
|
const cliBackends = normalizeStringList([
|
|
...record.cliBackends,
|
|
...(record.setup?.cliBackends ?? []),
|
|
]);
|
|
if (cliBackends) {
|
|
indexRecord.cliBackends = cliBackends;
|
|
}
|
|
const setupProviders = normalizeStringList(
|
|
record.setup?.providers?.map((provider) => provider.id),
|
|
);
|
|
if (setupProviders) {
|
|
indexRecord.setupProviders = setupProviders;
|
|
}
|
|
const channelConfigs = normalizeStringList(Object.keys(record.channelConfigs ?? {}));
|
|
if (channelConfigs) {
|
|
indexRecord.channelConfigs = channelConfigs;
|
|
}
|
|
const modelCatalogProviders = normalizeStringList(
|
|
Object.keys(record.modelCatalog?.providers ?? {}),
|
|
);
|
|
if (modelCatalogProviders) {
|
|
indexRecord.modelCatalogProviders = modelCatalogProviders;
|
|
}
|
|
const commandAliases = normalizeStringList(record.commandAliases?.map((alias) => alias.name));
|
|
if (commandAliases) {
|
|
indexRecord.commandAliases = commandAliases;
|
|
}
|
|
const contractKeys = collectContractKeys(record);
|
|
if (contractKeys) {
|
|
indexRecord.contractKeys = contractKeys;
|
|
}
|
|
if (record.enabledByDefault === true) {
|
|
indexRecord.enabledByDefault = true;
|
|
}
|
|
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;
|
|
});
|
|
|
|
return {
|
|
version: INSTALLED_PLUGIN_INDEX_VERSION,
|
|
warning: INSTALLED_PLUGIN_INDEX_WARNING,
|
|
hostContractVersion: resolveCompatibilityHostVersion(env),
|
|
compatRegistryVersion: resolveCompatRegistryVersion(),
|
|
migrationVersion: INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION,
|
|
policyHash: resolveInstalledPluginIndexPolicyHash(params.config),
|
|
generatedAtMs,
|
|
...(params.refreshReason ? { refreshReason: params.refreshReason } : {}),
|
|
installRecords,
|
|
plugins,
|
|
diagnostics,
|
|
};
|
|
}
|
|
|
|
export function loadInstalledPluginIndex(
|
|
params: LoadInstalledPluginIndexParams = {},
|
|
): InstalledPluginIndex {
|
|
return buildInstalledPluginIndex(params);
|
|
}
|
|
|
|
export function refreshInstalledPluginIndex(
|
|
params: RefreshInstalledPluginIndexParams,
|
|
): InstalledPluginIndex {
|
|
return buildInstalledPluginIndex({ ...params, cache: false, refreshReason: params.reason });
|
|
}
|
|
|
|
export function listInstalledPluginRecords(
|
|
index: InstalledPluginIndex,
|
|
): readonly InstalledPluginIndexRecord[] {
|
|
return index.plugins;
|
|
}
|
|
|
|
export function listEnabledInstalledPluginRecords(
|
|
index: InstalledPluginIndex,
|
|
config?: OpenClawConfig,
|
|
): readonly InstalledPluginIndexRecord[] {
|
|
if (!config) {
|
|
return index.plugins.filter((plugin) => plugin.enabled);
|
|
}
|
|
const normalizedConfig = normalizePluginsConfig(config?.plugins);
|
|
return index.plugins.filter(
|
|
(plugin) =>
|
|
resolveEffectiveEnableState({
|
|
id: plugin.pluginId,
|
|
origin: plugin.origin,
|
|
config: normalizedConfig,
|
|
rootConfig: config,
|
|
enabledByDefault: plugin.enabledByDefault,
|
|
}).enabled,
|
|
);
|
|
}
|
|
|
|
export function getInstalledPluginRecord(
|
|
index: InstalledPluginIndex,
|
|
pluginId: string,
|
|
): InstalledPluginIndexRecord | undefined {
|
|
return index.plugins.find((plugin) => plugin.pluginId === pluginId);
|
|
}
|
|
|
|
export function isInstalledPluginEnabled(
|
|
index: InstalledPluginIndex,
|
|
pluginId: string,
|
|
config?: OpenClawConfig,
|
|
): boolean {
|
|
const record = getInstalledPluginRecord(index, pluginId);
|
|
if (!record) {
|
|
return false;
|
|
}
|
|
if (!config) {
|
|
return record.enabled;
|
|
}
|
|
const normalizedConfig = normalizePluginsConfig(config?.plugins);
|
|
return resolveEffectiveEnableState({
|
|
id: record.pluginId,
|
|
origin: record.origin,
|
|
config: normalizedConfig,
|
|
rootConfig: config,
|
|
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));
|
|
}
|