mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
1723 lines
62 KiB
TypeScript
1723 lines
62 KiB
TypeScript
// Structured plugin catalog and lifecycle operations shared by Gateway-facing surfaces.
|
|
import path from "node:path";
|
|
import { isDeepStrictEqual } from "node:util";
|
|
import { asSafeIntegerInRange } from "@openclaw/normalization-core/number-coercion";
|
|
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
|
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
|
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope-config.js";
|
|
import { MANIFEST_KEY } from "../compat/legacy-names.js";
|
|
import { collectChangedPaths } from "../config/config-change-paths.js";
|
|
import {
|
|
assertConfigWriteAllowedInCurrentMode,
|
|
readConfigFileSnapshotForWrite,
|
|
replaceConfigFile,
|
|
} from "../config/config.js";
|
|
import { resolveIsNixMode } from "../config/paths.js";
|
|
import { ensurePluginAllowlisted } from "../config/plugins-allowlist.js";
|
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
|
import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js";
|
|
import { formatErrorMessage } from "../infra/errors.js";
|
|
import { buildNpmResolutionFields, type NpmSpecResolution } from "../infra/install-source-utils.js";
|
|
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
|
|
import type { RuntimeEnv } from "../runtime.js";
|
|
import { installBundledPluginSource } from "./bundled-install.js";
|
|
import type { BundledPluginSource } from "./bundled-sources.js";
|
|
import { CLAWHUB_INSTALL_ERROR_CODE } from "./clawhub-error-codes.js";
|
|
import {
|
|
buildClawHubPluginInstallRecordFields,
|
|
type ClawHubPluginInstallRecordFields,
|
|
} from "./clawhub-install-records.js";
|
|
import { installPluginFromClawHub } from "./clawhub.js";
|
|
import { enableExplicitlySelectedPluginInConfig } from "./enable.js";
|
|
import { installPluginFromGitSpec } from "./git-install.js";
|
|
import { resolveDefaultPluginExtensionsDir } from "./install-paths.js";
|
|
import {
|
|
resolveInstallConfigMutationPreflights,
|
|
selectInstallMutationWriteOptions,
|
|
persistPluginInstall,
|
|
type ConfigSnapshotForInstallPersist,
|
|
} from "./install-persistence.js";
|
|
import { commitPluginInstallRecordsWithConfig } from "./install-record-commit.js";
|
|
import type { InstallSafetyOverrides } from "./install-security-scan.js";
|
|
import type { InstallPolicyWarningDetails } from "./install-security-scan.types.js";
|
|
import type { PluginInstallLogger } from "./install-types.js";
|
|
import {
|
|
installPluginFromNpmPackArchive,
|
|
installPluginFromNpmSpec,
|
|
installPluginFromPath,
|
|
} from "./install.js";
|
|
import {
|
|
loadInstalledPluginIndexInstallRecords,
|
|
removePluginInstallRecordFromRecords,
|
|
withPluginInstallRecords,
|
|
withoutPluginInstallRecords,
|
|
} from "./installed-plugin-index-records.js";
|
|
import type { PluginManifestRecord } from "./manifest-registry.js";
|
|
import type { PluginDiagnostic } from "./manifest-types.js";
|
|
import {
|
|
resolveTrustedOfficialClawHubPackageName,
|
|
resolveTrustedSourceLinkedOfficialClawHubSpec,
|
|
resolveTrustedSourceLinkedOfficialNpmSpec,
|
|
} from "./official-external-install-records.js";
|
|
import {
|
|
getOfficialExternalPluginCatalogManifest,
|
|
listOfficialExternalPluginCatalogEntries,
|
|
loadConfiguredHostedOfficialExternalPluginCatalogEntries,
|
|
resolveOfficialExternalPluginId,
|
|
resolveOfficialExternalPluginInstall,
|
|
resolveOfficialExternalPluginLabel,
|
|
type HostedOfficialExternalPluginCatalogLoadResult,
|
|
type OfficialExternalPluginCatalogEntry,
|
|
} from "./official-external-plugin-catalog.js";
|
|
import { withPluginLifecycleLease } from "./plugin-lifecycle-lease.js";
|
|
import { registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle.js";
|
|
import {
|
|
loadPluginMetadataSnapshot,
|
|
resolvePluginMetadataSnapshot,
|
|
type PluginMetadataSnapshot,
|
|
} from "./plugin-metadata-snapshot.js";
|
|
import { resolveManifestProviderAuthChoices } from "./provider-auth-choices.js";
|
|
import { listRecommendedToolInstalls } from "./recommended-tool-installs.js";
|
|
import { refreshPluginRegistryAfterConfigMutation } from "./registry-refresh.js";
|
|
import { applySlotSelectionForPlugin } from "./slot-selection.js";
|
|
import { setPluginEnabledInConfig } from "./toggle-config.js";
|
|
import { collectClawPluginUninstallWarnings } from "./uninstall-claw-references.js";
|
|
import {
|
|
applyPluginUninstallDirectoryRemoval,
|
|
formatUninstallActionLabels,
|
|
planPluginUninstall,
|
|
pluginUninstallTargetExists,
|
|
prepareConfigForPendingPluginDirectoryRemoval,
|
|
} from "./uninstall.js";
|
|
|
|
type ManagedPluginCatalogEntry = {
|
|
id: string;
|
|
name: string;
|
|
packageName?: string;
|
|
description?: string;
|
|
version?: string;
|
|
kind?: string[];
|
|
origin?: string;
|
|
installed: boolean;
|
|
enabled: boolean;
|
|
state: "enabled" | "disabled" | "not-installed" | "error";
|
|
featured?: boolean;
|
|
featuredAt?: number;
|
|
order?: number;
|
|
hasIcon?: boolean;
|
|
install?: { source: "clawhub"; packageName: string } | { source: "official"; pluginId: string };
|
|
error?: string;
|
|
category?: string;
|
|
removable?: boolean;
|
|
};
|
|
|
|
type ManagedPluginCatalog = {
|
|
plugins: ManagedPluginCatalogEntry[];
|
|
diagnostics: unknown[];
|
|
mutationAllowed: boolean;
|
|
};
|
|
|
|
export type ManagedPluginInstallRequest =
|
|
| {
|
|
source: "clawhub";
|
|
packageName: string;
|
|
version?: string;
|
|
acknowledgeClawHubRisk?: boolean;
|
|
installPolicyWarningAcknowledgement?: {
|
|
warning: InstallPolicyWarningDetails;
|
|
resolvedRequest: ManagedPluginSourceInstallRequest;
|
|
};
|
|
}
|
|
| {
|
|
source: "official";
|
|
pluginId: string;
|
|
installPolicyWarningAcknowledgement?: {
|
|
warning: InstallPolicyWarningDetails;
|
|
resolvedRequest: ManagedPluginSourceInstallRequest;
|
|
};
|
|
};
|
|
|
|
export type ManagedPluginSourceInstallRequest =
|
|
| {
|
|
source: "local";
|
|
path: string;
|
|
recordSource: "archive" | "path";
|
|
mode: "install" | "update";
|
|
link?: boolean;
|
|
successMessage?: string;
|
|
}
|
|
| {
|
|
source: "npm-pack";
|
|
archivePath: string;
|
|
mode: "install" | "update";
|
|
}
|
|
| { source: "git"; spec: string; mode: "install" | "update" }
|
|
| {
|
|
source: "clawhub";
|
|
spec: string;
|
|
mode?: "install" | "update";
|
|
expectedPluginId?: string;
|
|
expectedIntegrity?: string;
|
|
acknowledgeClawHubRisk?: boolean;
|
|
onClawHubRisk?: NonNullable<Parameters<typeof installPluginFromClawHub>[0]["onClawHubRisk"]>;
|
|
}
|
|
| {
|
|
source: "bundled";
|
|
rawSpec: string;
|
|
bundledSource: BundledPluginSource;
|
|
warning?: string;
|
|
}
|
|
| {
|
|
source: "official";
|
|
spec: string;
|
|
pluginId: string;
|
|
expectedIntegrity?: string;
|
|
mode: "install" | "update";
|
|
pin?: boolean;
|
|
}
|
|
| {
|
|
source: "npm";
|
|
spec: string;
|
|
mode: "install" | "update";
|
|
pin?: boolean;
|
|
expectedPluginId?: string;
|
|
expectedIntegrity?: string;
|
|
trustedSourceLinkedOfficialInstall?: boolean;
|
|
allowBundledFallback?: boolean;
|
|
};
|
|
|
|
type ManagedPluginSourceInstallResult =
|
|
| {
|
|
ok: true;
|
|
pluginId: string;
|
|
config: OpenClawConfig;
|
|
warnings?: string[];
|
|
targetDir?: string;
|
|
version?: string;
|
|
npmResolution?: NpmSpecResolution;
|
|
clawhub?: ClawHubPluginInstallRecordFields;
|
|
}
|
|
| {
|
|
ok: false;
|
|
error: string;
|
|
code?: string;
|
|
version?: string;
|
|
warning?: string;
|
|
installPolicyWarning?: InstallPolicyWarningDetails;
|
|
};
|
|
|
|
type SourceInstallerResult =
|
|
| {
|
|
ok: false;
|
|
error: string;
|
|
code?: string;
|
|
version?: string;
|
|
warning?: string;
|
|
installPolicyWarning?: InstallPolicyWarningDetails;
|
|
}
|
|
| {
|
|
ok: true;
|
|
pluginId: string;
|
|
targetDir: string;
|
|
version?: string;
|
|
npmResolution?: NpmSpecResolution;
|
|
};
|
|
|
|
export class ManagedPluginLifecycleError extends Error {
|
|
readonly kind: "invalid-request" | "unavailable";
|
|
readonly code?: string;
|
|
readonly version?: string;
|
|
readonly warning?: string;
|
|
readonly installPolicyWarning?: InstallPolicyWarningDetails;
|
|
readonly installPolicyResolvedRequest?: ManagedPluginSourceInstallRequest;
|
|
|
|
constructor(
|
|
message: string,
|
|
details?: {
|
|
kind?: "invalid-request" | "unavailable";
|
|
code?: string;
|
|
version?: string;
|
|
warning?: string;
|
|
installPolicyWarning?: InstallPolicyWarningDetails;
|
|
installPolicyResolvedRequest?: ManagedPluginSourceInstallRequest;
|
|
cause?: unknown;
|
|
},
|
|
) {
|
|
super(message, details?.cause !== undefined ? { cause: details.cause } : undefined);
|
|
this.name = "ManagedPluginLifecycleError";
|
|
this.kind = details?.kind ?? "invalid-request";
|
|
this.code = details?.code;
|
|
this.version = details?.version;
|
|
this.warning = details?.warning;
|
|
this.installPolicyWarning = details?.installPolicyWarning;
|
|
this.installPolicyResolvedRequest = details?.installPolicyResolvedRequest;
|
|
}
|
|
}
|
|
|
|
type OfficialCatalogResult = Pick<HostedOfficialExternalPluginCatalogLoadResult, "entries"> & {
|
|
error?: string;
|
|
hostedFeaturedAuthoritative?: boolean;
|
|
};
|
|
|
|
let officialCatalogCache:
|
|
| { key: string; result: Promise<HostedOfficialExternalPluginCatalogLoadResult> }
|
|
| undefined;
|
|
|
|
const OFFICIAL_CATALOG_CACHE_KEY = "built-in";
|
|
|
|
/** Clear the process-stable hosted catalog snapshot after an explicit owner reload. */
|
|
export function clearManagedPluginOfficialCatalogCache(): void {
|
|
officialCatalogCache = undefined;
|
|
}
|
|
|
|
registerPluginMetadataProcessMemoLifecycleClear(clearManagedPluginOfficialCatalogCache);
|
|
|
|
function resolveCatalogManifestIcon(manifest: unknown): string | undefined {
|
|
if (!manifest || typeof manifest !== "object") {
|
|
return undefined;
|
|
}
|
|
return normalizeOptionalString((manifest as { icon?: unknown }).icon);
|
|
}
|
|
|
|
function resolveCatalogEntryIcon(entry: OfficialExternalPluginCatalogEntry | undefined) {
|
|
return (
|
|
normalizeOptionalString(entry?.icon) ??
|
|
resolveCatalogManifestIcon(getOfficialExternalPluginCatalogManifest(entry ?? {}))
|
|
);
|
|
}
|
|
|
|
function mergeCatalogMetadata(
|
|
hosted: OfficialExternalPluginCatalogEntry,
|
|
bundled: OfficialExternalPluginCatalogEntry,
|
|
options: { hostedFeaturedAuthoritative: boolean },
|
|
): OfficialExternalPluginCatalogEntry {
|
|
const hostedManifest = getOfficialExternalPluginCatalogManifest(hosted);
|
|
const bundledManifest = getOfficialExternalPluginCatalogManifest(bundled);
|
|
const bundledCatalog = bundledManifest?.catalog;
|
|
const bundledPlugin = bundledManifest?.plugin;
|
|
const bundledIcon = resolveCatalogManifestIcon(bundledManifest);
|
|
const bundledName = normalizeOptionalString(bundled.name);
|
|
const bundledDescription = normalizeOptionalString(bundled.description);
|
|
const bundledKind = normalizeOptionalString(bundled.kind);
|
|
const bundledSource = normalizeOptionalString(bundled.source);
|
|
const hostedFeatured = typeof hosted.featured === "boolean" ? hosted.featured : false;
|
|
const mergedCatalog =
|
|
bundledCatalog ||
|
|
hostedManifest?.catalog ||
|
|
(options.hostedFeaturedAuthoritative && hostedFeatured)
|
|
? {
|
|
...hostedManifest?.catalog,
|
|
...bundledCatalog,
|
|
...(options.hostedFeaturedAuthoritative ? { featured: hostedFeatured } : {}),
|
|
}
|
|
: undefined;
|
|
if (!mergedCatalog && !bundledPlugin) {
|
|
return hosted;
|
|
}
|
|
return {
|
|
...hosted,
|
|
...(!normalizeOptionalString(hosted.name) && bundledName ? { name: bundledName } : {}),
|
|
...(!normalizeOptionalString(hosted.description) && bundledDescription
|
|
? { description: bundledDescription }
|
|
: {}),
|
|
...(!normalizeOptionalString(hosted.kind) && bundledKind ? { kind: bundledKind } : {}),
|
|
...(!normalizeOptionalString(hosted.source) && bundledSource ? { source: bundledSource } : {}),
|
|
[MANIFEST_KEY]: {
|
|
...hostedManifest,
|
|
...(bundledPlugin ? { plugin: { ...hostedManifest?.plugin, ...bundledPlugin } } : {}),
|
|
...(mergedCatalog ? { catalog: mergedCatalog } : {}),
|
|
...(!resolveCatalogManifestIcon(hostedManifest) && bundledIcon ? { icon: bundledIcon } : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
type CatalogPackageSourceIdentity = {
|
|
source: "clawhub" | "npm";
|
|
packageName: string;
|
|
};
|
|
|
|
function resolveCatalogPackageSourceIdentities(
|
|
entry: OfficialExternalPluginCatalogEntry,
|
|
): CatalogPackageSourceIdentity[] {
|
|
const install = resolveOfficialExternalPluginInstall(entry);
|
|
const clawhubPackage = install?.clawhubSpec
|
|
? parseClawHubPluginSpec(install.clawhubSpec)?.name
|
|
: undefined;
|
|
const npmPackage = install?.npmSpec ? parseRegistryNpmSpec(install.npmSpec)?.name : undefined;
|
|
return [
|
|
...(clawhubPackage ? [{ source: "clawhub" as const, packageName: clawhubPackage }] : []),
|
|
...(npmPackage ? [{ source: "npm" as const, packageName: npmPackage }] : []),
|
|
];
|
|
}
|
|
|
|
function matchesBundledCatalogIdentity(params: {
|
|
hosted: OfficialExternalPluginCatalogEntry;
|
|
bundled: OfficialExternalPluginCatalogEntry;
|
|
}): boolean {
|
|
const hostedSources = resolveCatalogPackageSourceIdentities(params.hosted);
|
|
const bundledSources = resolveCatalogPackageSourceIdentities(params.bundled);
|
|
return hostedSources.some((hosted) =>
|
|
bundledSources.some(
|
|
(bundled) => bundled.source === hosted.source && bundled.packageName === hosted.packageName,
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Overlay local runtime identity and ordering after an exact package/source match.
|
|
* Hosted curation wins; bundled Featured state survives only in fallback mode.
|
|
*/
|
|
function overlayBundledOfficialPluginCatalogMetadata(
|
|
entries: readonly OfficialExternalPluginCatalogEntry[],
|
|
bundledEntries: readonly OfficialExternalPluginCatalogEntry[] = listOfficialExternalPluginCatalogEntries(),
|
|
options: { hostedFeaturedAuthoritative: boolean } = {
|
|
hostedFeaturedAuthoritative: false,
|
|
},
|
|
): OfficialExternalPluginCatalogEntry[] {
|
|
return entries.map((entry) => {
|
|
const matches = bundledEntries.filter((bundled) =>
|
|
matchesBundledCatalogIdentity({ hosted: entry, bundled }),
|
|
);
|
|
const bundled = matches.length === 1 ? matches[0] : undefined;
|
|
if (bundled) {
|
|
return mergeCatalogMetadata(entry, bundled, options);
|
|
}
|
|
if (!options.hostedFeaturedAuthoritative) {
|
|
return entry;
|
|
}
|
|
const hostedManifest = getOfficialExternalPluginCatalogManifest(entry);
|
|
if (entry.featured !== true && !hostedManifest?.catalog) {
|
|
return entry;
|
|
}
|
|
return {
|
|
...entry,
|
|
[MANIFEST_KEY]: {
|
|
...hostedManifest,
|
|
catalog: {
|
|
...hostedManifest?.catalog,
|
|
featured: entry.featured === true,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
async function loadOfficialCatalog(): Promise<OfficialCatalogResult> {
|
|
const key = OFFICIAL_CATALOG_CACHE_KEY;
|
|
if (officialCatalogCache?.key !== key) {
|
|
officialCatalogCache = {
|
|
key,
|
|
result: loadConfiguredHostedOfficialExternalPluginCatalogEntries(),
|
|
};
|
|
}
|
|
const result = await officialCatalogCache.result;
|
|
const hostedFeaturedAuthoritative =
|
|
result.source === "hosted" || result.source === "hosted-snapshot";
|
|
return {
|
|
entries: overlayBundledOfficialPluginCatalogMetadata(result.entries, undefined, {
|
|
hostedFeaturedAuthoritative,
|
|
}),
|
|
hostedFeaturedAuthoritative,
|
|
...("error" in result ? { error: result.error } : {}),
|
|
};
|
|
}
|
|
|
|
function normalizeKinds(kind: string | readonly string[] | undefined): string[] | undefined {
|
|
const values = (typeof kind === "string" ? [kind] : (kind ?? []))
|
|
.map((value) => value.trim())
|
|
.filter(Boolean);
|
|
return values.length > 0 ? [...new Set(values)] : undefined;
|
|
}
|
|
|
|
function normalizeCatalogMetadata(
|
|
value: unknown,
|
|
): { featured?: boolean; order?: number } | undefined {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return undefined;
|
|
}
|
|
const record = value as Record<string, unknown>;
|
|
const featured = typeof record.featured === "boolean" ? record.featured : undefined;
|
|
const order =
|
|
typeof record.order === "number" && Number.isFinite(record.order) ? record.order : undefined;
|
|
return featured === undefined && order === undefined
|
|
? undefined
|
|
: {
|
|
...(featured !== undefined ? { featured } : {}),
|
|
...(order !== undefined ? { order } : {}),
|
|
};
|
|
}
|
|
|
|
function normalizeFeaturedAt(value: unknown): number | undefined {
|
|
return asSafeIntegerInRange(value, { min: 0 });
|
|
}
|
|
|
|
function resolveCatalogInstallAction(params: {
|
|
entry: OfficialExternalPluginCatalogEntry;
|
|
pluginId: string;
|
|
}): ManagedPluginCatalogEntry["install"] {
|
|
const install = resolveOfficialExternalPluginInstall(params.entry);
|
|
const clawhub = install?.clawhubSpec ? parseClawHubPluginSpec(install.clawhubSpec) : undefined;
|
|
if (clawhub && !clawhub.version) {
|
|
return { source: "clawhub", packageName: clawhub.name };
|
|
}
|
|
return install ? { source: "official", pluginId: params.pluginId } : undefined;
|
|
}
|
|
|
|
/** Coarse manifest-derived grouping so catalog UIs can shelve a large inventory. */
|
|
function derivePluginCategory(manifest: PluginManifestRecord | undefined): string | undefined {
|
|
if (!manifest) {
|
|
return undefined;
|
|
}
|
|
if (manifest.channels.length > 0 || Object.keys(manifest.channelConfigs ?? {}).length > 0) {
|
|
return "channel";
|
|
}
|
|
const mediaProvider =
|
|
Object.keys(manifest.imageGenerationProviderMetadata ?? {}).length > 0 ||
|
|
Object.keys(manifest.videoGenerationProviderMetadata ?? {}).length > 0 ||
|
|
Object.keys(manifest.musicGenerationProviderMetadata ?? {}).length > 0 ||
|
|
Object.keys(manifest.mediaUnderstandingProviderMetadata ?? {}).length > 0;
|
|
if (
|
|
manifest.providers.length > 0 ||
|
|
manifest.providerEndpoints?.length ||
|
|
manifest.modelCatalog ||
|
|
mediaProvider
|
|
) {
|
|
return "provider";
|
|
}
|
|
const kinds = normalizeKinds(manifest.kind);
|
|
if (kinds?.includes("memory")) {
|
|
return "memory";
|
|
}
|
|
if (kinds?.includes("context-engine")) {
|
|
return "context-engine";
|
|
}
|
|
if (
|
|
manifest.contracts?.tools?.length ||
|
|
Object.keys(manifest.toolMetadata ?? {}).length > 0 ||
|
|
manifest.skills.length > 0
|
|
) {
|
|
return "tool";
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function firstPluginError(
|
|
diagnostics: readonly PluginDiagnostic[],
|
|
pluginId: string,
|
|
): string | undefined {
|
|
return diagnostics.find(
|
|
(diagnostic) => diagnostic.level === "error" && diagnostic.pluginId === pluginId,
|
|
)?.message;
|
|
}
|
|
|
|
function compareCatalogEntries(
|
|
left: ManagedPluginCatalogEntry,
|
|
right: ManagedPluginCatalogEntry,
|
|
): number {
|
|
const featured = Number(Boolean(right.featured)) - Number(Boolean(left.featured));
|
|
if (featured !== 0) {
|
|
return featured;
|
|
}
|
|
if (left.featured && right.featured) {
|
|
const leftFeaturedAt = left.featuredAt;
|
|
const rightFeaturedAt = right.featuredAt;
|
|
if (leftFeaturedAt !== undefined || rightFeaturedAt !== undefined) {
|
|
if (leftFeaturedAt === undefined) {
|
|
return 1;
|
|
}
|
|
if (rightFeaturedAt === undefined) {
|
|
return -1;
|
|
}
|
|
if (leftFeaturedAt !== rightFeaturedAt) {
|
|
return rightFeaturedAt - leftFeaturedAt;
|
|
}
|
|
}
|
|
}
|
|
const order = (left.order ?? Number.MAX_SAFE_INTEGER) - (right.order ?? Number.MAX_SAFE_INTEGER);
|
|
return order !== 0 ? order : left.name.localeCompare(right.name);
|
|
}
|
|
|
|
function resolveInstalledOfficialCatalogEntry(params: {
|
|
entries: readonly OfficialExternalPluginCatalogEntry[];
|
|
packageName?: string;
|
|
source: CatalogPackageSourceIdentity["source"];
|
|
}): OfficialExternalPluginCatalogEntry | undefined {
|
|
if (!params.packageName) {
|
|
return undefined;
|
|
}
|
|
const matches = params.entries.filter((entry) =>
|
|
resolveCatalogPackageSourceIdentities(entry).some(
|
|
(identity) =>
|
|
identity.source === params.source && identity.packageName === params.packageName,
|
|
),
|
|
);
|
|
return matches.length === 1 ? matches[0] : undefined;
|
|
}
|
|
|
|
function resolveOfficialCatalogIconUrl(
|
|
entries: readonly OfficialExternalPluginCatalogEntry[],
|
|
pluginId: string,
|
|
): string | undefined {
|
|
const entry = entries.find(
|
|
(candidate) => resolveOfficialExternalPluginId(candidate) === pluginId,
|
|
);
|
|
return resolveCatalogEntryIcon(entry);
|
|
}
|
|
|
|
type PluginIndexRecord = PluginMetadataSnapshot["index"]["plugins"][number];
|
|
|
|
function resolveInstalledHostedOfficialEntry(params: {
|
|
record: PluginIndexRecord;
|
|
installRecord?: PluginInstallRecord;
|
|
officialEntries: readonly OfficialExternalPluginCatalogEntry[];
|
|
bundledOfficialEntries: readonly OfficialExternalPluginCatalogEntry[];
|
|
}): {
|
|
entry?: OfficialExternalPluginCatalogEntry;
|
|
hasPublishedIdentity: boolean;
|
|
} {
|
|
const trustedOfficialClawHubSpec = params.installRecord
|
|
? resolveTrustedSourceLinkedOfficialClawHubSpec({
|
|
pluginId: params.record.pluginId,
|
|
record: params.installRecord,
|
|
})
|
|
: undefined;
|
|
const trustedOfficialNpmSpec = params.installRecord
|
|
? resolveTrustedSourceLinkedOfficialNpmSpec({
|
|
pluginId: params.record.pluginId,
|
|
record: params.installRecord,
|
|
})
|
|
: undefined;
|
|
const sourceLinkedOfficialClawHubPackage = trustedOfficialClawHubSpec
|
|
? parseClawHubPluginSpec(trustedOfficialClawHubSpec)?.name
|
|
: undefined;
|
|
const currentOfficialClawHubPackage = params.installRecord
|
|
? resolveTrustedOfficialClawHubPackageName(params.installRecord)
|
|
: undefined;
|
|
const trustedOfficialNpmPackage = trustedOfficialNpmSpec
|
|
? parseRegistryNpmSpec(trustedOfficialNpmSpec)?.name
|
|
: undefined;
|
|
const bundledPublishedEntry =
|
|
params.record.origin === "bundled"
|
|
? resolveInstalledOfficialCatalogEntry({
|
|
entries: params.bundledOfficialEntries,
|
|
packageName: params.record.packageName,
|
|
source: "npm",
|
|
})
|
|
: undefined;
|
|
const installedOfficialIdentity = sourceLinkedOfficialClawHubPackage
|
|
? { source: "clawhub" as const, packageName: sourceLinkedOfficialClawHubPackage }
|
|
: trustedOfficialNpmPackage
|
|
? { source: "npm" as const, packageName: trustedOfficialNpmPackage }
|
|
: currentOfficialClawHubPackage &&
|
|
(!params.record.packageName ||
|
|
params.record.packageName === currentOfficialClawHubPackage)
|
|
? { source: "clawhub" as const, packageName: currentOfficialClawHubPackage }
|
|
: bundledPublishedEntry && params.record.packageName
|
|
? { source: "npm" as const, packageName: params.record.packageName }
|
|
: undefined;
|
|
const hasInstalledOfficialProvenance = Boolean(
|
|
installedOfficialIdentity &&
|
|
(!params.record.packageName ||
|
|
params.record.packageName === installedOfficialIdentity.packageName),
|
|
);
|
|
const bundledOfficialEntry =
|
|
bundledPublishedEntry ??
|
|
resolveInstalledOfficialCatalogEntry({
|
|
entries: params.bundledOfficialEntries,
|
|
packageName: hasInstalledOfficialProvenance
|
|
? installedOfficialIdentity?.packageName
|
|
: undefined,
|
|
source: installedOfficialIdentity?.source ?? "clawhub",
|
|
});
|
|
const hostedPackageName =
|
|
installedOfficialIdentity?.source === "npm"
|
|
? (bundledOfficialEntry
|
|
? resolveCatalogPackageSourceIdentities(bundledOfficialEntry)
|
|
: []
|
|
).find((identity) => identity.source === "clawhub")?.packageName
|
|
: installedOfficialIdentity?.packageName;
|
|
return {
|
|
entry: resolveInstalledOfficialCatalogEntry({
|
|
entries: params.officialEntries,
|
|
packageName: hasInstalledOfficialProvenance ? hostedPackageName : undefined,
|
|
source: "clawhub",
|
|
}),
|
|
hasPublishedIdentity: Boolean(hasInstalledOfficialProvenance && hostedPackageName),
|
|
};
|
|
}
|
|
|
|
function resolvePluginIconUrlFromCatalogFacts(params: {
|
|
metadata: PluginMetadataSnapshot;
|
|
officialEntries: readonly OfficialExternalPluginCatalogEntry[];
|
|
bundledOfficialEntries?: readonly OfficialExternalPluginCatalogEntry[];
|
|
pluginId: string;
|
|
}): string | undefined {
|
|
const normalizedPluginId = params.metadata.normalizePluginId(params.pluginId);
|
|
const record = params.metadata.index.plugins.find(
|
|
(candidate) => params.metadata.normalizePluginId(candidate.pluginId) === normalizedPluginId,
|
|
);
|
|
const localIcon = normalizeOptionalString(
|
|
params.metadata.byPluginId.get(normalizedPluginId)?.icon,
|
|
);
|
|
if (!record) {
|
|
return resolveOfficialCatalogIconUrl(params.officialEntries, normalizedPluginId);
|
|
}
|
|
const { entry: officialEntry } = resolveInstalledHostedOfficialEntry({
|
|
record,
|
|
installRecord: params.metadata.index.installRecords[record.pluginId],
|
|
officialEntries: params.officialEntries,
|
|
bundledOfficialEntries:
|
|
params.bundledOfficialEntries ?? listOfficialExternalPluginCatalogEntries(),
|
|
});
|
|
return resolveCatalogEntryIcon(officialEntry) ?? localIcon;
|
|
}
|
|
|
|
function resolveManagedPluginMetadataParams(config: OpenClawConfig, env: NodeJS.ProcessEnv) {
|
|
return {
|
|
config,
|
|
env,
|
|
workspaceDir: resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config), env),
|
|
};
|
|
}
|
|
|
|
/** Resolve the current manifest/catalog icon URL without accepting a caller-provided URL. */
|
|
export async function resolveManagedPluginIconUrl(params: {
|
|
config: OpenClawConfig;
|
|
pluginId: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
officialCatalog?: OfficialCatalogResult;
|
|
}): Promise<string | undefined> {
|
|
const env = params.env ?? process.env;
|
|
const metadata = resolvePluginMetadataSnapshot(
|
|
resolveManagedPluginMetadataParams(params.config, env),
|
|
);
|
|
const officialCatalog = params.officialCatalog ?? (await loadOfficialCatalog());
|
|
return resolvePluginIconUrlFromCatalogFacts({
|
|
metadata,
|
|
officialEntries: officialCatalog.entries,
|
|
bundledOfficialEntries: listOfficialExternalPluginCatalogEntries(),
|
|
pluginId: params.pluginId,
|
|
});
|
|
}
|
|
|
|
function normalizeManagedCatalogIconUrl(value: unknown): string | undefined {
|
|
const normalized = normalizeOptionalString(value);
|
|
if (!normalized || normalized.length > 2048) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
const url = new URL(normalized);
|
|
return url.protocol === "https:" && url.hostname && !url.username && !url.password && !url.hash
|
|
? url.href
|
|
: undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/** Resolve only URLs currently owned by a manifest or bundled presentation catalog. */
|
|
export function resolveManagedSetupCatalogIconUrl(params: {
|
|
config: OpenClawConfig;
|
|
iconUrl: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
}): string | undefined {
|
|
const requested = normalizeManagedCatalogIconUrl(params.iconUrl);
|
|
if (!requested) {
|
|
return undefined;
|
|
}
|
|
const env = params.env ?? process.env;
|
|
const allowedUrls = [
|
|
...resolveManifestProviderAuthChoices({
|
|
config: params.config,
|
|
env,
|
|
includeUntrustedWorkspacePlugins: false,
|
|
includeWorkspacePlugins: false,
|
|
}).map((choice) => choice.icon),
|
|
...listRecommendedToolInstalls().map((install) => install.icon),
|
|
];
|
|
return allowedUrls.some((iconUrl) => normalizeManagedCatalogIconUrl(iconUrl) === requested)
|
|
? requested
|
|
: undefined;
|
|
}
|
|
|
|
/** Build cold installed state merged with the hosted official catalog and bundled curation. */
|
|
export async function listManagedPlugins(params: {
|
|
config: OpenClawConfig;
|
|
env?: NodeJS.ProcessEnv;
|
|
officialCatalog?: OfficialCatalogResult;
|
|
}): Promise<ManagedPluginCatalog> {
|
|
const env = params.env ?? process.env;
|
|
const metadata = resolvePluginMetadataSnapshot(
|
|
resolveManagedPluginMetadataParams(params.config, env),
|
|
);
|
|
const officialCatalog = params.officialCatalog ?? (await loadOfficialCatalog());
|
|
const bundledOfficialEntries = listOfficialExternalPluginCatalogEntries();
|
|
const plugins = metadata.index.plugins.map((record): ManagedPluginCatalogEntry => {
|
|
const manifest = metadata.byPluginId.get(record.pluginId);
|
|
const localCatalog = normalizeCatalogMetadata(manifest?.catalog);
|
|
const installRecord = metadata.index.installRecords[record.pluginId];
|
|
const { entry: officialEntry, hasPublishedIdentity } = resolveInstalledHostedOfficialEntry({
|
|
record,
|
|
installRecord,
|
|
officialEntries: officialCatalog.entries,
|
|
bundledOfficialEntries,
|
|
});
|
|
const hasHostedOfficialIdentity = hasPublishedIdentity;
|
|
const officialCatalogMetadata = officialEntry
|
|
? normalizeCatalogMetadata(getOfficialExternalPluginCatalogManifest(officialEntry)?.catalog)
|
|
: undefined;
|
|
// Published plugin curation follows the live feed even after install, including
|
|
// omission. Private bundled plugins without an exact package/source match stay local.
|
|
const catalog =
|
|
hasHostedOfficialIdentity && officialCatalog.hostedFeaturedAuthoritative
|
|
? {
|
|
...localCatalog,
|
|
...officialCatalogMetadata,
|
|
featured: officialEntry?.featured === true,
|
|
}
|
|
: officialCatalogMetadata
|
|
? { ...localCatalog, ...officialCatalogMetadata }
|
|
: localCatalog;
|
|
const error = firstPluginError(metadata.diagnostics, record.pluginId);
|
|
const kind = normalizeKinds(manifest?.kind);
|
|
const category = derivePluginCategory(manifest);
|
|
// Only externally installed plugins (tracked install record, non-bundled) can be removed.
|
|
const removable =
|
|
record.origin !== "bundled" && Boolean(metadata.index.installRecords[record.pluginId]);
|
|
// Prefer human labels over package specifiers: the registry backfills a
|
|
// missing manifest name with the npm package name, which is an install
|
|
// spec rather than a display name.
|
|
const manifestName =
|
|
manifest?.name && manifest.name !== record.packageName ? manifest.name : undefined;
|
|
const localName = manifestName ?? manifest?.channelCatalogMeta?.label ?? record.pluginId;
|
|
const localDescription =
|
|
manifest?.description ?? manifest?.channelCatalogMeta?.blurb ?? manifest?.packageDescription;
|
|
const hostedListingAuthoritative =
|
|
hasHostedOfficialIdentity && officialCatalog.hostedFeaturedAuthoritative;
|
|
const featuredAt =
|
|
hostedListingAuthoritative && catalog?.featured === true
|
|
? normalizeFeaturedAt(officialEntry?.featuredAt)
|
|
: undefined;
|
|
const name =
|
|
(hostedListingAuthoritative ? normalizeOptionalString(officialEntry?.title) : undefined) ??
|
|
localName;
|
|
const description =
|
|
(hostedListingAuthoritative
|
|
? normalizeOptionalString(officialEntry?.description)
|
|
: undefined) ?? localDescription;
|
|
return {
|
|
id: record.pluginId,
|
|
name,
|
|
...(record.packageName ? { packageName: record.packageName } : {}),
|
|
...(description ? { description } : {}),
|
|
...(record.packageVersion || manifest?.version
|
|
? { version: record.packageVersion ?? manifest?.version }
|
|
: {}),
|
|
...(kind ? { kind } : {}),
|
|
...(record.origin ? { origin: record.origin } : {}),
|
|
installed: true,
|
|
enabled: record.enabled,
|
|
state: error ? "error" : record.enabled ? "enabled" : "disabled",
|
|
...(catalog?.featured !== undefined ? { featured: catalog.featured } : {}),
|
|
...(featuredAt !== undefined ? { featuredAt } : {}),
|
|
...(catalog?.order !== undefined ? { order: catalog.order } : {}),
|
|
...(resolvePluginIconUrlFromCatalogFacts({
|
|
metadata,
|
|
officialEntries: officialCatalog.entries,
|
|
bundledOfficialEntries,
|
|
pluginId: record.pluginId,
|
|
})
|
|
? { hasIcon: true }
|
|
: {}),
|
|
...(error ? { error } : {}),
|
|
...(category ? { category } : {}),
|
|
removable,
|
|
};
|
|
});
|
|
const installedIds = new Set(plugins.map((plugin) => plugin.id));
|
|
const installedPackageNames = new Set(
|
|
plugins.flatMap((plugin) => (plugin.packageName ? [plugin.packageName] : [])),
|
|
);
|
|
// Hosted rows without a declared runtime id fall back to their package name,
|
|
// so id matching alone would keep them visible after a successful install.
|
|
const entryPackageInstalled = (entry: OfficialExternalPluginCatalogEntry) =>
|
|
resolveCatalogPackageSourceIdentities(entry).some((identity) =>
|
|
installedPackageNames.has(identity.packageName),
|
|
);
|
|
for (const entry of officialCatalog.entries) {
|
|
const pluginId = resolveOfficialExternalPluginId(entry);
|
|
const manifest = getOfficialExternalPluginCatalogManifest(entry);
|
|
const manifestCatalog = normalizeCatalogMetadata(manifest?.catalog);
|
|
const catalog =
|
|
manifestCatalog || typeof entry.featured === "boolean"
|
|
? {
|
|
...manifestCatalog,
|
|
...(manifestCatalog?.featured === undefined && typeof entry.featured === "boolean"
|
|
? { featured: entry.featured }
|
|
: {}),
|
|
}
|
|
: undefined;
|
|
if (!pluginId || !catalog || installedIds.has(pluginId) || entryPackageInstalled(entry)) {
|
|
continue;
|
|
}
|
|
const kind = normalizeKinds(entry.kind);
|
|
const install = resolveCatalogInstallAction({ entry, pluginId });
|
|
const description = normalizeOptionalString(entry.description);
|
|
const version = normalizeOptionalString(entry.version);
|
|
const featuredAt =
|
|
catalog.featured === true ? normalizeFeaturedAt(entry.featuredAt) : undefined;
|
|
plugins.push({
|
|
id: pluginId,
|
|
name: resolveOfficialExternalPluginLabel(entry),
|
|
...(description ? { description } : {}),
|
|
...(version ? { version } : {}),
|
|
...(kind ? { kind } : {}),
|
|
origin: "official",
|
|
installed: false,
|
|
enabled: false,
|
|
state: "not-installed",
|
|
...(catalog.featured !== undefined ? { featured: catalog.featured } : {}),
|
|
...(featuredAt !== undefined ? { featuredAt } : {}),
|
|
...(catalog.order !== undefined ? { order: catalog.order } : {}),
|
|
...(resolveCatalogEntryIcon(entry) ? { hasIcon: true } : {}),
|
|
...(install ? { install } : {}),
|
|
});
|
|
}
|
|
const diagnostics: unknown[] = [...metadata.diagnostics];
|
|
if (officialCatalog.error) {
|
|
diagnostics.push({
|
|
level: "warn",
|
|
message: `Official plugin catalog fallback: ${officialCatalog.error}`,
|
|
});
|
|
}
|
|
return {
|
|
plugins: plugins.toSorted(compareCatalogEntries),
|
|
diagnostics,
|
|
mutationAllowed: !resolveIsNixMode(env),
|
|
};
|
|
}
|
|
|
|
function assertValidConfigSnapshot(
|
|
prepared: Awaited<ReturnType<typeof readConfigFileSnapshotForWrite>>,
|
|
): ConfigSnapshotForInstallPersist {
|
|
const { snapshot, writeOptions } = prepared;
|
|
if (!snapshot.valid) {
|
|
throw new ManagedPluginLifecycleError(
|
|
"Config invalid; run `openclaw doctor --fix` before managing plugins.",
|
|
);
|
|
}
|
|
const mutationWriteOptions = selectInstallMutationWriteOptions(writeOptions);
|
|
const { pluginMutation } = resolveInstallConfigMutationPreflights({
|
|
parsed: (snapshot.parsed ?? {}) as Record<string, unknown>,
|
|
snapshotPath: snapshot.path,
|
|
writeOptions: mutationWriteOptions,
|
|
});
|
|
if (pluginMutation.mode === "blocked") {
|
|
throw new ManagedPluginLifecycleError(pluginMutation.reason);
|
|
}
|
|
return {
|
|
config: snapshot.sourceConfig,
|
|
baseHash: snapshot.hash,
|
|
writeOptions: mutationWriteOptions,
|
|
};
|
|
}
|
|
|
|
async function readPluginMutationSnapshot(
|
|
env: NodeJS.ProcessEnv,
|
|
): Promise<ConfigSnapshotForInstallPersist> {
|
|
try {
|
|
assertConfigWriteAllowedInCurrentMode({ env });
|
|
} catch (error) {
|
|
throw new ManagedPluginLifecycleError(formatErrorMessage(error), { cause: error });
|
|
}
|
|
return assertValidConfigSnapshot(await readConfigFileSnapshotForWrite());
|
|
}
|
|
|
|
function createSilentRuntime(): RuntimeEnv {
|
|
return {
|
|
log: () => undefined,
|
|
error: () => undefined,
|
|
exit: (code) => {
|
|
throw new ManagedPluginLifecycleError(`plugin lifecycle exited with code ${code}`);
|
|
},
|
|
};
|
|
}
|
|
|
|
function createInstallLogger(warnings: string[]) {
|
|
return {
|
|
info: () => undefined,
|
|
warn: (message: string) => warnings.push(message),
|
|
};
|
|
}
|
|
|
|
function resolveOfficialEntryById(
|
|
entries: readonly OfficialExternalPluginCatalogEntry[],
|
|
pluginId: string,
|
|
): OfficialExternalPluginCatalogEntry | undefined {
|
|
return entries.find((entry) => resolveOfficialExternalPluginId(entry) === pluginId);
|
|
}
|
|
|
|
/** Explicitly declared runtime id, ignoring the entry-id fallback used for display. */
|
|
function resolveDeclaredOfficialPluginId(
|
|
entry: OfficialExternalPluginCatalogEntry,
|
|
): string | undefined {
|
|
const manifest = getOfficialExternalPluginCatalogManifest(entry);
|
|
return (
|
|
normalizeOptionalString(manifest?.plugin?.id) ??
|
|
normalizeOptionalString(manifest?.channel?.id) ??
|
|
normalizeOptionalString(manifest?.providers?.[0]?.id)
|
|
);
|
|
}
|
|
|
|
function resolveOfficialEntryByClawHubPackage(
|
|
entries: readonly OfficialExternalPluginCatalogEntry[],
|
|
packageName: string,
|
|
): OfficialExternalPluginCatalogEntry | undefined {
|
|
// Bundled identities remain the local trust anchor when a hosted feed omits
|
|
// its ClawHub candidate; hosted install/version metadata is never copied back.
|
|
return [...listOfficialExternalPluginCatalogEntries(), ...entries].find((entry) => {
|
|
const install = resolveOfficialExternalPluginInstall(entry);
|
|
return parseClawHubPluginSpec(install?.clawhubSpec ?? "")?.name === packageName;
|
|
});
|
|
}
|
|
|
|
function resolveHostedOfficialEntryByClawHubPackage(
|
|
entries: readonly OfficialExternalPluginCatalogEntry[],
|
|
packageName: string,
|
|
): OfficialExternalPluginCatalogEntry | undefined {
|
|
return entries.find((entry) => {
|
|
const install = resolveOfficialExternalPluginInstall(entry);
|
|
return parseClawHubPluginSpec(install?.clawhubSpec ?? "")?.name === packageName;
|
|
});
|
|
}
|
|
|
|
function buildClawHubSpec(packageName: string, version?: string): string {
|
|
const parsed = parseClawHubPluginSpec(`clawhub:${packageName}`);
|
|
if (!parsed || parsed.version) {
|
|
throw new ManagedPluginLifecycleError(`invalid ClawHub package name: ${packageName}`);
|
|
}
|
|
return `clawhub:${packageName}${version ? `@${version}` : ""}`;
|
|
}
|
|
|
|
function pinInstallPolicyResolvedRequest(
|
|
request: ManagedPluginSourceInstallRequest,
|
|
version: string | undefined,
|
|
): ManagedPluginSourceInstallRequest {
|
|
if (request.source !== "clawhub" || !version) {
|
|
return request;
|
|
}
|
|
const parsed = parseClawHubPluginSpec(request.spec);
|
|
return parsed ? { ...request, spec: buildClawHubSpec(parsed.name, version) } : request;
|
|
}
|
|
|
|
function throwInstallFailure(
|
|
result: {
|
|
error: string;
|
|
code?: string;
|
|
version?: string;
|
|
warning?: string;
|
|
installPolicyWarning?: InstallPolicyWarningDetails;
|
|
},
|
|
resolvedRequest?: ManagedPluginSourceInstallRequest,
|
|
): never {
|
|
const unavailable =
|
|
!result.code ||
|
|
result.code === CLAWHUB_INSTALL_ERROR_CODE.ARTIFACT_UNAVAILABLE ||
|
|
result.code === CLAWHUB_INSTALL_ERROR_CODE.ARTIFACT_DOWNLOAD_UNAVAILABLE ||
|
|
result.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE;
|
|
throw new ManagedPluginLifecycleError(result.error, {
|
|
kind: unavailable ? "unavailable" : "invalid-request",
|
|
code: result.code,
|
|
version: result.version,
|
|
warning: result.warning,
|
|
installPolicyWarning: result.installPolicyWarning,
|
|
...(result.installPolicyWarning && resolvedRequest
|
|
? {
|
|
installPolicyResolvedRequest: pinInstallPolicyResolvedRequest(
|
|
resolvedRequest,
|
|
result.version,
|
|
),
|
|
}
|
|
: {}),
|
|
cause: result,
|
|
});
|
|
}
|
|
|
|
function installRecordOwnsTarget(
|
|
record: PluginInstallRecord | undefined,
|
|
targetDir: string,
|
|
): boolean {
|
|
return Boolean(
|
|
record?.installPath && path.resolve(record.installPath) === path.resolve(targetDir),
|
|
);
|
|
}
|
|
|
|
async function cleanupFailedManagedPluginInstall(params: {
|
|
env: NodeJS.ProcessEnv;
|
|
pluginId: string;
|
|
install: PluginInstallRecord;
|
|
targetDir: string;
|
|
extensionsDir: string;
|
|
}): Promise<string[]> {
|
|
let installRecords: Record<string, PluginInstallRecord>;
|
|
try {
|
|
installRecords = await loadInstalledPluginIndexInstallRecords({ env: params.env });
|
|
} catch (error) {
|
|
return [
|
|
`Could not verify whether the failed plugin install was committed; retained ${params.targetDir}: ${formatErrorMessage(error)}`,
|
|
];
|
|
}
|
|
if (installRecordOwnsTarget(installRecords[params.pluginId], params.targetDir)) {
|
|
return [
|
|
`Plugin install persistence reported an error after ${params.targetDir} was recorded; retained the managed target.`,
|
|
];
|
|
}
|
|
|
|
const plan = planPluginUninstall({
|
|
config: {
|
|
plugins: { installs: { [params.pluginId]: params.install } },
|
|
},
|
|
pluginId: params.pluginId,
|
|
deleteFiles: true,
|
|
extensionsDir: params.extensionsDir,
|
|
});
|
|
if (!plan.ok) {
|
|
return [`Could not plan cleanup for failed plugin install: ${plan.error}`];
|
|
}
|
|
if (!plan.directoryRemoval) {
|
|
return [
|
|
`Could not resolve a managed cleanup target for failed plugin install ${params.pluginId}.`,
|
|
];
|
|
}
|
|
if (path.resolve(plan.directoryRemoval.target) !== path.resolve(params.targetDir)) {
|
|
return [
|
|
`Refused cleanup for failed plugin install ${params.pluginId}: planned target does not match the newly installed target.`,
|
|
];
|
|
}
|
|
try {
|
|
const cleanup = await applyPluginUninstallDirectoryRemoval(plan.directoryRemoval);
|
|
return cleanup.warnings;
|
|
} catch (error) {
|
|
return [
|
|
`Failed to remove the newly installed target after plugin persistence failed: ${formatErrorMessage(error)}`,
|
|
];
|
|
}
|
|
}
|
|
|
|
function throwPersistenceFailureWithCleanupWarnings(error: unknown, warnings: string[]): never {
|
|
if (warnings.length === 0) {
|
|
throw error;
|
|
}
|
|
const cleanupWarning = [...new Set(warnings)].join("\n");
|
|
if (error instanceof ManagedPluginLifecycleError) {
|
|
throw new ManagedPluginLifecycleError(error.message, {
|
|
kind: error.kind,
|
|
code: error.code,
|
|
version: error.version,
|
|
warning: [error.warning, cleanupWarning].filter(Boolean).join("\n"),
|
|
installPolicyWarning: error.installPolicyWarning,
|
|
installPolicyResolvedRequest: error.installPolicyResolvedRequest,
|
|
cause: error,
|
|
});
|
|
}
|
|
throw new ManagedPluginLifecycleError(formatErrorMessage(error), {
|
|
kind: "unavailable",
|
|
warning: cleanupWarning,
|
|
cause: error,
|
|
});
|
|
}
|
|
|
|
async function persistManagedSourceInstall(params: {
|
|
env: NodeJS.ProcessEnv;
|
|
snapshot: ConfigSnapshotForInstallPersist;
|
|
pluginId: string;
|
|
install: PluginInstallRecord;
|
|
targetDir: string;
|
|
extensionsDir: string;
|
|
invalidateRuntimeCache?: boolean;
|
|
runtime?: RuntimeEnv;
|
|
persistenceLogger?: PluginInstallLogger;
|
|
successMessage?: string;
|
|
cleanupOnPersistenceFailure?: boolean;
|
|
}): Promise<OpenClawConfig> {
|
|
const persist = () =>
|
|
persistPluginInstall({
|
|
snapshot: params.snapshot,
|
|
pluginId: params.pluginId,
|
|
install: params.install,
|
|
invalidateRuntimeCache: params.invalidateRuntimeCache,
|
|
runtime: params.runtime,
|
|
...(params.persistenceLogger ? { persistenceLogger: params.persistenceLogger } : {}),
|
|
...(params.successMessage ? { successMessage: params.successMessage } : {}),
|
|
});
|
|
if (!params.cleanupOnPersistenceFailure) {
|
|
return await persist();
|
|
}
|
|
try {
|
|
return await persist();
|
|
} catch (error) {
|
|
const warnings = await cleanupFailedManagedPluginInstall(params);
|
|
return throwPersistenceFailureWithCleanupWarnings(error, warnings);
|
|
}
|
|
}
|
|
|
|
/** Execute one resolved plugin source through the shared install-and-persist pipeline. */
|
|
export async function installManagedPluginSource(params: {
|
|
request: ManagedPluginSourceInstallRequest;
|
|
snapshot: ConfigSnapshotForInstallPersist;
|
|
env?: NodeJS.ProcessEnv;
|
|
logger?: PluginInstallLogger & { terminalLinks?: boolean };
|
|
// Source loggers can feed chat replies; management diagnostics require explicit private opt-in.
|
|
persistenceLogger?: PluginInstallLogger;
|
|
safetyOverrides?: InstallSafetyOverrides;
|
|
runtime?: RuntimeEnv;
|
|
invalidateRuntimeCache?: boolean;
|
|
cleanupOnPersistenceFailure?: boolean;
|
|
}): Promise<ManagedPluginSourceInstallResult> {
|
|
const { request } = params;
|
|
const env = params.env ?? process.env;
|
|
const extensionsDir = resolveDefaultPluginExtensionsDir(env);
|
|
if (request.source === "bundled") {
|
|
const result = await installBundledPluginSource({
|
|
snapshot: params.snapshot,
|
|
rawSpec: request.rawSpec,
|
|
bundledSource: request.bundledSource,
|
|
warning: request.warning,
|
|
invalidateRuntimeCache: params.invalidateRuntimeCache,
|
|
runtime: params.runtime,
|
|
});
|
|
return {
|
|
ok: true,
|
|
...result,
|
|
config: params.snapshot.config,
|
|
};
|
|
}
|
|
|
|
const common = {
|
|
...params.safetyOverrides,
|
|
config: params.snapshot.config,
|
|
extensionsDir,
|
|
logger: params.logger,
|
|
};
|
|
const complete = async <T extends SourceInstallerResult>(
|
|
installResult: Promise<T>,
|
|
completed: {
|
|
install: (result: Extract<T, { ok: true }>) => PluginInstallRecord;
|
|
expectedPluginId?: string;
|
|
targetDir?: string;
|
|
snapshot?: ConfigSnapshotForInstallPersist;
|
|
successMessage?: string;
|
|
},
|
|
): Promise<ManagedPluginSourceInstallResult> => {
|
|
const result = await installResult;
|
|
if (!result.ok) {
|
|
return result;
|
|
}
|
|
const installed = result as Extract<T, { ok: true }> & {
|
|
pluginId: string;
|
|
targetDir: string;
|
|
};
|
|
if (completed.expectedPluginId && installed.pluginId !== completed.expectedPluginId) {
|
|
return {
|
|
ok: false as const,
|
|
error: `official catalog plugin id mismatch: expected ${completed.expectedPluginId}, got ${installed.pluginId}`,
|
|
};
|
|
}
|
|
const targetDir = completed.targetDir ?? installed.targetDir;
|
|
// Links point at operator-owned source directories. Every published managed
|
|
// payload defaults to compensation, but link persistence never deletes source.
|
|
const cleanupOnPersistenceFailure =
|
|
request.source === "local" && request.link
|
|
? false
|
|
: (params.cleanupOnPersistenceFailure ?? true);
|
|
const config = await persistManagedSourceInstall({
|
|
...params,
|
|
cleanupOnPersistenceFailure,
|
|
env,
|
|
snapshot: completed.snapshot ?? params.snapshot,
|
|
pluginId: installed.pluginId,
|
|
install: completed.install(installed),
|
|
targetDir,
|
|
extensionsDir,
|
|
successMessage: completed.successMessage,
|
|
});
|
|
return { ...installed, config };
|
|
};
|
|
|
|
if (request.source === "local") {
|
|
const installPath = request.link ? request.path : undefined;
|
|
const linkedSnapshot = request.link
|
|
? {
|
|
...params.snapshot,
|
|
config: {
|
|
...params.snapshot.config,
|
|
plugins: {
|
|
...params.snapshot.config.plugins,
|
|
load: {
|
|
...params.snapshot.config.plugins?.load,
|
|
paths: uniqueStrings([
|
|
...(params.snapshot.config.plugins?.load?.paths ?? []),
|
|
request.path,
|
|
]),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
: params.snapshot;
|
|
return await complete(
|
|
installPluginFromPath({
|
|
...common,
|
|
path: request.path,
|
|
mode: request.mode,
|
|
...(request.link ? { dryRun: true, allowSourceTypeScriptEntries: true } : {}),
|
|
}),
|
|
{
|
|
snapshot: linkedSnapshot,
|
|
targetDir: installPath,
|
|
successMessage: request.successMessage,
|
|
install: (result) => ({
|
|
source: request.recordSource,
|
|
sourcePath: request.path,
|
|
installPath: installPath ?? result.targetDir,
|
|
version: result.version,
|
|
}),
|
|
},
|
|
);
|
|
}
|
|
|
|
if (request.source === "npm-pack") {
|
|
return await complete(
|
|
installPluginFromNpmPackArchive({
|
|
...common,
|
|
archivePath: request.archivePath,
|
|
mode: request.mode,
|
|
}),
|
|
{
|
|
install: (result) => ({
|
|
source: "npm",
|
|
spec: result.npmResolution?.resolvedSpec ?? result.manifestName ?? result.pluginId,
|
|
sourcePath: request.archivePath,
|
|
installPath: result.targetDir,
|
|
...(result.version ? { version: result.version } : {}),
|
|
...buildNpmResolutionFields(result.npmResolution),
|
|
artifactKind: "npm-pack",
|
|
artifactFormat: "tgz",
|
|
...(result.npmResolution?.integrity
|
|
? { npmIntegrity: result.npmResolution.integrity }
|
|
: {}),
|
|
...(result.npmResolution?.shasum ? { npmShasum: result.npmResolution.shasum } : {}),
|
|
...(result.npmTarballName ? { npmTarballName: result.npmTarballName } : {}),
|
|
}),
|
|
},
|
|
);
|
|
}
|
|
|
|
if (request.source === "git") {
|
|
return await complete(
|
|
installPluginFromGitSpec({ ...common, spec: request.spec, mode: request.mode }),
|
|
{
|
|
install: (result) => ({
|
|
source: "git",
|
|
spec: request.spec,
|
|
installPath: result.targetDir,
|
|
version: result.version,
|
|
resolvedAt: result.git.resolvedAt,
|
|
gitUrl: result.git.url,
|
|
gitRef: result.git.ref,
|
|
gitCommit: result.git.commit,
|
|
}),
|
|
},
|
|
);
|
|
}
|
|
|
|
if (request.source === "clawhub") {
|
|
return await complete(
|
|
installPluginFromClawHub({
|
|
...common,
|
|
spec: request.spec,
|
|
mode: request.mode,
|
|
...(request.expectedPluginId ? { expectedPluginId: request.expectedPluginId } : {}),
|
|
...(request.expectedIntegrity ? { expectedIntegrity: request.expectedIntegrity } : {}),
|
|
...(request.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}),
|
|
...(request.onClawHubRisk ? { onClawHubRisk: request.onClawHubRisk } : {}),
|
|
}),
|
|
{
|
|
expectedPluginId: request.expectedPluginId,
|
|
install: (result) => ({
|
|
...buildClawHubPluginInstallRecordFields(result.clawhub),
|
|
spec: request.spec,
|
|
installPath: result.targetDir,
|
|
}),
|
|
},
|
|
);
|
|
}
|
|
|
|
const expectedPluginId =
|
|
request.source === "official" ? request.pluginId : request.expectedPluginId;
|
|
return await complete(
|
|
installPluginFromNpmSpec({
|
|
...common,
|
|
spec: request.spec,
|
|
mode: request.mode,
|
|
...(request.source === "official" || request.trustedSourceLinkedOfficialInstall
|
|
? { trustedSourceLinkedOfficialInstall: true }
|
|
: {}),
|
|
...(expectedPluginId ? { expectedPluginId } : {}),
|
|
...(request.expectedIntegrity ? { expectedIntegrity: request.expectedIntegrity } : {}),
|
|
}),
|
|
{
|
|
expectedPluginId,
|
|
install: (result) => ({
|
|
source: "npm",
|
|
spec: request.pin ? (result.npmResolution?.resolvedSpec ?? request.spec) : request.spec,
|
|
installPath: result.targetDir,
|
|
...(result.version ? { version: result.version } : {}),
|
|
...buildNpmResolutionFields(result.npmResolution),
|
|
}),
|
|
},
|
|
);
|
|
}
|
|
|
|
function resolveManagedClawHubInstallRequest(params: {
|
|
request: Extract<ManagedPluginInstallRequest, { source: "clawhub" }>;
|
|
officialEntries: readonly OfficialExternalPluginCatalogEntry[];
|
|
expectedIntegrity?: string;
|
|
}): Extract<ManagedPluginSourceInstallRequest, { source: "clawhub" }> {
|
|
const packageName = params.request.packageName.trim();
|
|
const official = resolveOfficialEntryByClawHubPackage(params.officialEntries, packageName);
|
|
// Pin the runtime id only when the catalog entry declares one; the entry-id
|
|
// fallback is just the package name and would reject legitimate installs.
|
|
const expectedPluginId = official ? resolveDeclaredOfficialPluginId(official) : undefined;
|
|
const hostedOfficial = resolveHostedOfficialEntryByClawHubPackage(
|
|
params.officialEntries,
|
|
packageName,
|
|
);
|
|
const hostedInstall = hostedOfficial
|
|
? resolveOfficialExternalPluginInstall(hostedOfficial)
|
|
: undefined;
|
|
const hostedClawHub = parseClawHubPluginSpec(hostedInstall?.clawhubSpec ?? "");
|
|
const requestMatchesHostedCandidate =
|
|
!params.request.version || params.request.version === hostedClawHub?.version;
|
|
const version =
|
|
params.request.version ?? (requestMatchesHostedCandidate ? hostedClawHub?.version : undefined);
|
|
const expectedIntegrity =
|
|
params.expectedIntegrity ??
|
|
(requestMatchesHostedCandidate ? hostedInstall?.expectedIntegrity : undefined);
|
|
return {
|
|
source: "clawhub",
|
|
spec: buildClawHubSpec(packageName, version),
|
|
...(expectedPluginId ? { expectedPluginId } : {}),
|
|
...(expectedIntegrity ? { expectedIntegrity } : {}),
|
|
...(params.request.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}),
|
|
};
|
|
}
|
|
|
|
function resolveManagedOfficialInstallRequest(params: {
|
|
request: Extract<ManagedPluginInstallRequest, { source: "official" }>;
|
|
officialEntries: readonly OfficialExternalPluginCatalogEntry[];
|
|
}): ManagedPluginSourceInstallRequest {
|
|
const entry = resolveOfficialEntryById(params.officialEntries, params.request.pluginId);
|
|
if (!entry) {
|
|
throw new ManagedPluginLifecycleError(
|
|
`unknown official plugin catalog entry: ${params.request.pluginId}`,
|
|
);
|
|
}
|
|
const pluginId = resolveOfficialExternalPluginId(entry);
|
|
const install = resolveOfficialExternalPluginInstall(entry);
|
|
if (!pluginId || !install) {
|
|
throw new ManagedPluginLifecycleError(
|
|
`official plugin catalog entry is not installable: ${params.request.pluginId}`,
|
|
);
|
|
}
|
|
const clawhub = install.clawhubSpec ? parseClawHubPluginSpec(install.clawhubSpec) : undefined;
|
|
if (clawhub) {
|
|
return resolveManagedClawHubInstallRequest({
|
|
request: {
|
|
source: "clawhub",
|
|
packageName: clawhub.name,
|
|
...(clawhub.version ? { version: clawhub.version } : {}),
|
|
},
|
|
officialEntries: params.officialEntries,
|
|
...(install.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}),
|
|
});
|
|
}
|
|
if (!install.npmSpec) {
|
|
throw new ManagedPluginLifecycleError(
|
|
`official plugin catalog entry has no supported install source: ${params.request.pluginId}`,
|
|
);
|
|
}
|
|
return {
|
|
source: "official",
|
|
spec: install.npmSpec,
|
|
pluginId,
|
|
mode: "install",
|
|
...(install.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}),
|
|
};
|
|
}
|
|
|
|
/** Install a ClawHub or curated official plugin through the canonical install pipeline. */
|
|
export async function installManagedPlugin(params: {
|
|
request: ManagedPluginInstallRequest;
|
|
env?: NodeJS.ProcessEnv;
|
|
}): Promise<{ plugin: ManagedPluginCatalogEntry; warnings?: string[] }> {
|
|
const env = params.env ?? process.env;
|
|
return await withPluginLifecycleLease({ env }, async () => {
|
|
const snapshot = await readPluginMutationSnapshot(env);
|
|
const officialCatalog = await loadOfficialCatalog();
|
|
const warnings: string[] = [];
|
|
const installLogger = createInstallLogger(warnings);
|
|
let installPolicyWarningAcknowledgementAvailable = Boolean(
|
|
params.request.installPolicyWarningAcknowledgement,
|
|
);
|
|
const request =
|
|
params.request.installPolicyWarningAcknowledgement?.resolvedRequest ??
|
|
(params.request.source === "clawhub"
|
|
? resolveManagedClawHubInstallRequest({
|
|
request: params.request,
|
|
officialEntries: officialCatalog.entries,
|
|
})
|
|
: resolveManagedOfficialInstallRequest({
|
|
request: params.request,
|
|
officialEntries: officialCatalog.entries,
|
|
}));
|
|
const installed = await installManagedPluginSource({
|
|
request,
|
|
snapshot,
|
|
env,
|
|
logger: installLogger,
|
|
persistenceLogger: installLogger,
|
|
...(params.request.installPolicyWarningAcknowledgement
|
|
? {
|
|
safetyOverrides: {
|
|
onInstallPolicyWarning: async ({ warning }) => {
|
|
if (!installPolicyWarningAcknowledgementAvailable) {
|
|
return false;
|
|
}
|
|
installPolicyWarningAcknowledgementAvailable = false;
|
|
return isDeepStrictEqual(
|
|
warning,
|
|
params.request.installPolicyWarningAcknowledgement?.warning,
|
|
);
|
|
},
|
|
},
|
|
}
|
|
: {}),
|
|
cleanupOnPersistenceFailure: true,
|
|
invalidateRuntimeCache: false,
|
|
runtime: createSilentRuntime(),
|
|
});
|
|
if (!installed.ok) {
|
|
return throwInstallFailure(installed, request);
|
|
}
|
|
const catalog = await listManagedPlugins({
|
|
config: installed.config,
|
|
env,
|
|
officialCatalog,
|
|
});
|
|
const plugin = catalog.plugins.find((entry) => entry.id === installed.pluginId);
|
|
if (!plugin) {
|
|
throw new ManagedPluginLifecycleError(
|
|
`installed plugin missing from refreshed registry: ${installed.pluginId}`,
|
|
);
|
|
}
|
|
return {
|
|
plugin,
|
|
...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {}),
|
|
};
|
|
});
|
|
}
|
|
|
|
/** Persist desired plugin policy while preserving allow/deny, slot, include, and hash guards. */
|
|
export async function setManagedPluginEnabled(params: {
|
|
pluginId: string;
|
|
enabled: boolean;
|
|
env?: NodeJS.ProcessEnv;
|
|
}): Promise<{
|
|
plugin: ManagedPluginCatalogEntry;
|
|
changedPaths: string[];
|
|
warnings?: string[];
|
|
}> {
|
|
const env = params.env ?? process.env;
|
|
return await withPluginLifecycleLease({ env }, async () => {
|
|
const snapshot = await readPluginMutationSnapshot(env);
|
|
const metadata = loadPluginMetadataSnapshot(
|
|
resolveManagedPluginMetadataParams(snapshot.config, env),
|
|
);
|
|
const pluginId = metadata.normalizePluginId(params.pluginId.trim());
|
|
if (!metadata.index.plugins.some((plugin) => plugin.pluginId === pluginId)) {
|
|
throw new ManagedPluginLifecycleError(`plugin not installed: ${params.pluginId}`);
|
|
}
|
|
let next = snapshot.config;
|
|
const warnings: string[] = [];
|
|
let policyPluginId = pluginId;
|
|
if (params.enabled) {
|
|
// The admin-scoped enable RPC is an explicit trust action. Preserve the
|
|
// existing inventory while admitting only the selected installed plugin.
|
|
if ((next.plugins?.allow?.length ?? 0) > 0) {
|
|
next = ensurePluginAllowlisted(next, pluginId);
|
|
}
|
|
const enableResult = enableExplicitlySelectedPluginInConfig(next, pluginId, {
|
|
updateChannelConfig: false,
|
|
});
|
|
if (!enableResult.enabled) {
|
|
throw new ManagedPluginLifecycleError(
|
|
`plugin "${pluginId}" could not be enabled (${enableResult.reason ?? "unknown reason"})`,
|
|
);
|
|
}
|
|
next = enableResult.config;
|
|
policyPluginId = enableResult.pluginId;
|
|
const slotResult = applySlotSelectionForPlugin(next, pluginId);
|
|
next = slotResult.config;
|
|
warnings.push(...slotResult.warnings);
|
|
} else {
|
|
next = setPluginEnabledInConfig(next, pluginId, false, { updateChannelConfig: false });
|
|
}
|
|
const changedPaths = new Set<string>();
|
|
collectChangedPaths(snapshot.config, next, "", changedPaths);
|
|
await replaceConfigFile({
|
|
nextConfig: next,
|
|
baseHash: snapshot.baseHash,
|
|
writeOptions: snapshot.writeOptions,
|
|
});
|
|
await refreshPluginRegistryAfterConfigMutation({
|
|
config: next,
|
|
env,
|
|
reason: "policy-changed",
|
|
invalidateRuntimeCache: false,
|
|
policyPluginIds: [policyPluginId],
|
|
logger: { warn: (message) => warnings.push(message) },
|
|
});
|
|
const catalog = await listManagedPlugins({ config: next, env });
|
|
const plugin = catalog.plugins.find((entry) => entry.id === pluginId);
|
|
if (!plugin) {
|
|
throw new ManagedPluginLifecycleError(
|
|
`updated plugin missing from refreshed registry: ${pluginId}`,
|
|
);
|
|
}
|
|
return {
|
|
plugin,
|
|
changedPaths: [...changedPaths].filter(Boolean).toSorted(),
|
|
...(warnings.length > 0 ? { warnings } : {}),
|
|
};
|
|
});
|
|
}
|
|
|
|
/** Remove an installed plugin: config references, install record, and managed files. */
|
|
export async function uninstallManagedPlugin(params: {
|
|
pluginId: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
}): Promise<{ pluginId: string; removed: string[]; warnings?: string[] }> {
|
|
const env = params.env ?? process.env;
|
|
return await withPluginLifecycleLease({ env }, async () => {
|
|
const snapshot = await readPluginMutationSnapshot(env);
|
|
const installRecords = await loadInstalledPluginIndexInstallRecords({ env });
|
|
// Mirror the CLI uninstall flow: plan against config carrying install records
|
|
// so managed npm/git directories resolve, then persist the stripped config.
|
|
const configWithRecords = withPluginInstallRecords(snapshot.config, installRecords);
|
|
const metadata = loadPluginMetadataSnapshot(
|
|
resolveManagedPluginMetadataParams(configWithRecords, env),
|
|
);
|
|
const pluginId = metadata.normalizePluginId(params.pluginId.trim());
|
|
const record = metadata.index.plugins.find((plugin) => plugin.pluginId === pluginId);
|
|
if (record?.origin === "bundled") {
|
|
throw new ManagedPluginLifecycleError(
|
|
`bundled plugin cannot be uninstalled: ${pluginId}; disable it instead`,
|
|
);
|
|
}
|
|
// Preserve manifest ownership exactly; only missing metadata uses the plugin-id fallback.
|
|
const channelIds = metadata.byPluginId.get(pluginId)?.channels;
|
|
const extensionsDir = resolveDefaultPluginExtensionsDir(env);
|
|
const initialPlan = planPluginUninstall({
|
|
config: configWithRecords,
|
|
pluginId,
|
|
...(channelIds ? { channelIds } : {}),
|
|
deleteFiles: true,
|
|
extensionsDir,
|
|
});
|
|
if (!initialPlan.ok) {
|
|
throw new ManagedPluginLifecycleError(initialPlan.error);
|
|
}
|
|
let plan = initialPlan;
|
|
let finalSnapshot = snapshot;
|
|
let directoryResult = { directoryRemoved: false, warnings: [] as string[] };
|
|
if (plan.directoryRemoval) {
|
|
const disabledConfig = prepareConfigForPendingPluginDirectoryRemoval(
|
|
snapshot.config,
|
|
pluginId,
|
|
);
|
|
await replaceConfigFile({
|
|
nextConfig: disabledConfig,
|
|
baseHash: snapshot.baseHash,
|
|
writeOptions: {
|
|
...snapshot.writeOptions,
|
|
afterWrite: { mode: "auto" },
|
|
},
|
|
});
|
|
directoryResult = await applyPluginUninstallDirectoryRemoval(plan.directoryRemoval);
|
|
if (pluginUninstallTargetExists(plan.directoryRemoval.target)) {
|
|
throw new ManagedPluginLifecycleError(
|
|
`Failed to remove plugin directory ${plan.directoryRemoval.target}; the plugin remains disabled and tracked so uninstall can be retried.`,
|
|
{ kind: "unavailable" },
|
|
);
|
|
}
|
|
finalSnapshot = await readPluginMutationSnapshot(env);
|
|
const refreshedConfigWithRecords = withPluginInstallRecords(
|
|
finalSnapshot.config,
|
|
installRecords,
|
|
);
|
|
const refreshedPlan = planPluginUninstall({
|
|
config: refreshedConfigWithRecords,
|
|
pluginId,
|
|
...(channelIds ? { channelIds } : {}),
|
|
deleteFiles: true,
|
|
extensionsDir,
|
|
});
|
|
if (!refreshedPlan.ok) {
|
|
throw new ManagedPluginLifecycleError(refreshedPlan.error);
|
|
}
|
|
plan = refreshedPlan;
|
|
}
|
|
const nextConfig = withoutPluginInstallRecords(plan.config);
|
|
const nextInstallRecords = removePluginInstallRecordFromRecords(installRecords, pluginId);
|
|
await commitPluginInstallRecordsWithConfig({
|
|
previousInstallRecords: installRecords,
|
|
nextInstallRecords,
|
|
nextConfig,
|
|
baseHash: finalSnapshot.baseHash,
|
|
writeOptions: finalSnapshot.writeOptions,
|
|
});
|
|
const warnings = [
|
|
...collectClawPluginUninstallWarnings({
|
|
pluginId,
|
|
installRecord: installRecords[pluginId],
|
|
env,
|
|
}),
|
|
...directoryResult.warnings,
|
|
];
|
|
await refreshPluginRegistryAfterConfigMutation({
|
|
config: nextConfig,
|
|
env,
|
|
reason: "source-changed",
|
|
installRecords: nextInstallRecords,
|
|
invalidateRuntimeCache: false,
|
|
logger: { warn: (message) => warnings.push(message) },
|
|
});
|
|
const removed = formatUninstallActionLabels({
|
|
...plan.actions,
|
|
directory: directoryResult.directoryRemoved,
|
|
});
|
|
return {
|
|
pluginId,
|
|
removed,
|
|
...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {}),
|
|
};
|
|
});
|
|
}
|
|
|
|
/** Normalize unexpected lifecycle failures for Gateway response adapters. */
|
|
export function formatManagedPluginLifecycleError(error: unknown): string {
|
|
return formatErrorMessage(error);
|
|
}
|
|
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|