diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 6af70a4a7ff5..503b3c3f3c70 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -952,7 +952,6 @@ src/plugins/loader.test-harness.ts src/plugins/management-service.ts src/plugins/manifest-registry.test.ts src/plugins/manifest-registry.ts -src/plugins/manifest.ts src/plugins/marketplace.test.ts src/plugins/marketplace.ts src/plugins/official-external-plugin-catalog.test.ts diff --git a/src/plugins/manifest-capability-normalizers.ts b/src/plugins/manifest-capability-normalizers.ts new file mode 100644 index 000000000000..af0366b7d862 --- /dev/null +++ b/src/plugins/manifest-capability-normalizers.ts @@ -0,0 +1,469 @@ +import { normalizeOptionalString } from "../../packages/normalization-core/src/string-coerce.js"; +import { normalizeTrimmedStringList } from "../../packages/normalization-core/src/string-normalization.js"; +import { isBlockedObjectKey } from "../infra/prototype-keys.js"; +import { isRecord } from "../utils.js"; +import type { + PluginManifestCapabilityProviderAuthSignal, + PluginManifestCapabilityProviderConfigSignal, + PluginManifestCapabilityProviderMetadata, + PluginManifestCapabilityProviderModeConfigSignal, + PluginManifestCatalog, + PluginManifestConfigContracts, + PluginManifestConfigLiteral, + PluginManifestContracts, + PluginManifestDangerousConfigFlag, + PluginManifestMcpServer, + PluginManifestMediaUnderstandingCapability, + PluginManifestMediaUnderstandingProviderMetadata, + PluginManifestProviderBaseUrlGuard, + PluginManifestSecretInputContracts, + PluginManifestSecretInputPath, + PluginManifestToolMetadata, +} from "./manifest-types.js"; + +export function normalizeStringListRecord(value: unknown): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + const normalized: Record = Object.create(null); + for (const [key, rawValues] of Object.entries(value)) { + const providerId = normalizeOptionalString(key) ?? ""; + if (!providerId || isBlockedObjectKey(providerId)) { + continue; + } + const values = normalizeTrimmedStringList(rawValues); + if (values.length === 0) { + continue; + } + normalized[providerId] = values; + } + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +export function normalizeStringRecord(value: unknown): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + const normalized: Record = Object.create(null); + for (const [rawKey, rawValue] of Object.entries(value)) { + const key = normalizeOptionalString(rawKey) ?? ""; + const valueLocal = normalizeOptionalString(rawValue) ?? ""; + if (!key || isBlockedObjectKey(key) || !valueLocal) { + continue; + } + normalized[key] = valueLocal; + } + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +export function normalizeManifestMcpServers( + value: unknown, +): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + const normalized: Record = Object.create(null); + for (const [rawName, rawServer] of Object.entries(value)) { + const name = normalizeOptionalString(rawName) ?? ""; + if (!name || isBlockedObjectKey(name) || !isRecord(rawServer)) { + continue; + } + normalized[name] = { ...rawServer }; + } + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +function normalizeNamedMetadataRecord( + value: unknown, + normalizeEntry: (entry: Record) => T | undefined, +): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + const normalized: Record = Object.create(null); + for (const [rawId, rawEntry] of Object.entries(value)) { + const id = normalizeOptionalString(rawId) ?? ""; + const entry = + !id || isBlockedObjectKey(id) || !isRecord(rawEntry) ? undefined : normalizeEntry(rawEntry); + if (entry) { + normalized[id] = entry; + } + } + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +const MEDIA_UNDERSTANDING_CAPABILITIES = new Set(["image", "audio", "video"]); + +function normalizeMediaUnderstandingCapabilityRecord( + value: unknown, +): Partial> | undefined { + if (!isRecord(value)) { + return undefined; + } + const normalized: Partial> = {}; + for (const [rawKey, rawValue] of Object.entries(value)) { + if (!MEDIA_UNDERSTANDING_CAPABILITIES.has(rawKey)) { + continue; + } + const model = normalizeOptionalString(rawValue); + if (model) { + normalized[rawKey as PluginManifestMediaUnderstandingCapability] = model; + } + } + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +function normalizeMediaUnderstandingPriorityRecord( + value: unknown, +): Partial> | undefined { + if (!isRecord(value)) { + return undefined; + } + const normalized: Partial> = {}; + for (const [rawKey, rawValue] of Object.entries(value)) { + if ( + !MEDIA_UNDERSTANDING_CAPABILITIES.has(rawKey) || + typeof rawValue !== "number" || + !Number.isFinite(rawValue) + ) { + continue; + } + normalized[rawKey as PluginManifestMediaUnderstandingCapability] = rawValue; + } + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +function normalizeMediaUnderstandingCapabilities( + value: unknown, +): PluginManifestMediaUnderstandingCapability[] | undefined { + const values = normalizeTrimmedStringList(value).filter((entry) => + MEDIA_UNDERSTANDING_CAPABILITIES.has(entry), + ) as PluginManifestMediaUnderstandingCapability[]; + return values.length > 0 ? values : undefined; +} + +function normalizeMediaUnderstandingNativeDocumentInputs(value: unknown): Array<"pdf"> | undefined { + const values = normalizeTrimmedStringList(value).filter((entry) => entry === "pdf"); + return values.length > 0 ? values : undefined; +} + +function normalizeMediaUnderstandingDocumentModels( + value: unknown, +): PluginManifestMediaUnderstandingProviderMetadata["documentModels"] | undefined { + if (!isRecord(value)) { + return undefined; + } + const pdfRaw = value.pdf; + if (!isRecord(pdfRaw)) { + return undefined; + } + const textExtraction = normalizeOptionalString(pdfRaw.textExtraction); + const image: string | false | undefined = + pdfRaw.image === false ? false : normalizeOptionalString(pdfRaw.image); + const pdf = { + ...(textExtraction ? { textExtraction } : {}), + ...(image !== undefined ? { image } : {}), + }; + return Object.keys(pdf).length > 0 ? { pdf } : undefined; +} + +export function normalizeMediaUnderstandingProviderMetadata( + value: unknown, +): Record | undefined { + return normalizeNamedMetadataRecord(value, (rawMetadata) => { + const capabilities = normalizeMediaUnderstandingCapabilities(rawMetadata.capabilities); + const defaultModels = normalizeMediaUnderstandingCapabilityRecord(rawMetadata.defaultModels); + const autoPriority = normalizeMediaUnderstandingPriorityRecord(rawMetadata.autoPriority); + const nativeDocumentInputs = normalizeMediaUnderstandingNativeDocumentInputs( + rawMetadata.nativeDocumentInputs, + ); + const documentModels = normalizeMediaUnderstandingDocumentModels(rawMetadata.documentModels); + const metadata = { + ...(capabilities ? { capabilities } : {}), + ...(defaultModels ? { defaultModels } : {}), + ...(autoPriority ? { autoPriority } : {}), + ...(nativeDocumentInputs ? { nativeDocumentInputs } : {}), + ...(documentModels ? { documentModels } : {}), + } satisfies PluginManifestMediaUnderstandingProviderMetadata; + return Object.keys(metadata).length > 0 ? metadata : undefined; + }); +} + +function normalizeProviderBaseUrlGuard( + value: unknown, +): PluginManifestProviderBaseUrlGuard | undefined { + if (!isRecord(value)) { + return undefined; + } + const provider = normalizeOptionalString(value.provider); + const allowedBaseUrls = normalizeTrimmedStringList(value.allowedBaseUrls); + if (!provider || allowedBaseUrls.length === 0) { + return undefined; + } + const defaultBaseUrl = normalizeOptionalString(value.defaultBaseUrl); + return { + provider, + ...(defaultBaseUrl ? { defaultBaseUrl } : {}), + allowedBaseUrls, + }; +} + +function normalizeCapabilityProviderAuthSignals( + value: unknown, +): PluginManifestCapabilityProviderAuthSignal[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const signals: PluginManifestCapabilityProviderAuthSignal[] = []; + for (const rawSignal of value) { + if (!isRecord(rawSignal)) { + continue; + } + const provider = normalizeOptionalString(rawSignal.provider); + if (!provider) { + continue; + } + const providerBaseUrl = normalizeProviderBaseUrlGuard(rawSignal.providerBaseUrl); + signals.push({ + provider, + ...(providerBaseUrl ? { providerBaseUrl } : {}), + }); + } + return signals.length > 0 ? signals : undefined; +} + +function normalizeCapabilityProviderModeConfigSignal( + value: unknown, +): PluginManifestCapabilityProviderModeConfigSignal | undefined { + if (!isRecord(value)) { + return undefined; + } + const pathResult = normalizeOptionalString(value.path); + const defaultValue = normalizeOptionalString(value.default); + const allowed = normalizeTrimmedStringList(value.allowed); + const disallowed = normalizeTrimmedStringList(value.disallowed); + const signal = { + ...(pathResult ? { path: pathResult } : {}), + ...(defaultValue ? { default: defaultValue } : {}), + ...(allowed.length > 0 ? { allowed } : {}), + ...(disallowed.length > 0 ? { disallowed } : {}), + } satisfies PluginManifestCapabilityProviderModeConfigSignal; + return Object.keys(signal).length > 0 ? signal : undefined; +} + +function normalizeCapabilityProviderConfigSignals( + value: unknown, +): PluginManifestCapabilityProviderConfigSignal[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const signals: PluginManifestCapabilityProviderConfigSignal[] = []; + for (const rawSignal of value) { + if (!isRecord(rawSignal)) { + continue; + } + const rootPath = normalizeOptionalString(rawSignal.rootPath); + if (!rootPath) { + continue; + } + const overlayPath = normalizeOptionalString(rawSignal.overlayPath); + const overlayMapPath = normalizeOptionalString(rawSignal.overlayMapPath); + const required = normalizeTrimmedStringList(rawSignal.required); + const requiredAny = normalizeTrimmedStringList(rawSignal.requiredAny); + const mode = normalizeCapabilityProviderModeConfigSignal(rawSignal.mode); + const signal = { + rootPath, + ...(overlayPath ? { overlayPath } : {}), + ...(overlayMapPath ? { overlayMapPath } : {}), + ...(required.length > 0 ? { required } : {}), + ...(requiredAny.length > 0 ? { requiredAny } : {}), + ...(mode ? { mode } : {}), + } satisfies PluginManifestCapabilityProviderConfigSignal; + if (required.length > 0 || requiredAny.length > 0 || mode) { + signals.push(signal); + } + } + return signals.length > 0 ? signals : undefined; +} + +function normalizeCapabilityProviderMetadataEntry( + rawMetadata: Record, +): PluginManifestCapabilityProviderMetadata | undefined { + const aliases = normalizeTrimmedStringList(rawMetadata.aliases); + const authProviders = normalizeTrimmedStringList(rawMetadata.authProviders); + const authSignals = normalizeCapabilityProviderAuthSignals(rawMetadata.authSignals); + const configSignals = normalizeCapabilityProviderConfigSignals(rawMetadata.configSignals); + const referenceAudioInputs = rawMetadata.referenceAudioInputs === true ? true : undefined; + const metadata = { + ...(aliases.length > 0 ? { aliases } : {}), + ...(authProviders.length > 0 ? { authProviders } : {}), + ...(authSignals ? { authSignals } : {}), + ...(configSignals ? { configSignals } : {}), + ...(referenceAudioInputs ? { referenceAudioInputs } : {}), + } satisfies PluginManifestCapabilityProviderMetadata; + return Object.keys(metadata).length > 0 ? metadata : undefined; +} + +export function normalizeCapabilityProviderMetadata( + value: unknown, +): Record | undefined { + return normalizeNamedMetadataRecord(value, normalizeCapabilityProviderMetadataEntry); +} + +export function normalizePluginToolMetadata( + value: unknown, +): Record | undefined { + return normalizeNamedMetadataRecord(value, (rawMetadata) => { + const providerMetadata = normalizeCapabilityProviderMetadataEntry(rawMetadata); + const metadata = { + ...providerMetadata, + ...(rawMetadata.optional === true ? { optional: true } : {}), + ...(rawMetadata.replaySafe === true ? { replaySafe: true } : {}), + } satisfies PluginManifestToolMetadata; + return Object.keys(metadata).length > 0 ? metadata : undefined; + }); +} + +export function normalizeManifestCatalog(value: unknown): PluginManifestCatalog | undefined { + if (!isRecord(value)) { + return undefined; + } + const featured = typeof value.featured === "boolean" ? value.featured : undefined; + const order = + typeof value.order === "number" && Number.isFinite(value.order) ? value.order : undefined; + if (featured === undefined && order === undefined) { + return undefined; + } + return { + ...(featured !== undefined ? { featured } : {}), + ...(order !== undefined ? { order } : {}), + }; +} + +const MANIFEST_CONTRACT_KEYS = [ + "embeddedExtensionFactories", + "agentToolResultMiddleware", + "trustedToolPolicies", + "externalAuthProviders", + "embeddingProviders", + "memoryEmbeddingProviders", + "speechProviders", + "realtimeTranscriptionProviders", + "realtimeVoiceProviders", + "mediaUnderstandingProviders", + "transcriptSourceProviders", + "documentExtractors", + "imageGenerationProviders", + "videoGenerationProviders", + "musicGenerationProviders", + "webContentExtractors", + "webFetchProviders", + "webSearchProviders", + "workerProviders", + "usageProviders", + "migrationProviders", + "gatewayMethodDispatch", + "tools", +] as const satisfies readonly (keyof PluginManifestContracts)[]; + +export function normalizeManifestContracts(value: unknown): PluginManifestContracts | undefined { + if (!isRecord(value)) { + return undefined; + } + const contracts: PluginManifestContracts = {}; + for (const key of MANIFEST_CONTRACT_KEYS) { + const entries = normalizeTrimmedStringList(value[key]); + if (entries.length > 0) { + contracts[key] = entries; + } + } + return Object.keys(contracts).length > 0 ? contracts : undefined; +} + +function isManifestConfigLiteral(value: unknown): value is PluginManifestConfigLiteral { + return ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ); +} + +function normalizeManifestDangerousConfigFlags( + value: unknown, +): PluginManifestDangerousConfigFlag[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const normalized: PluginManifestDangerousConfigFlag[] = []; + for (const entry of value) { + if (!isRecord(entry)) { + continue; + } + const pathValue = normalizeOptionalString(entry.path) ?? ""; + if (!pathValue || !isManifestConfigLiteral(entry.equals)) { + continue; + } + normalized.push({ path: pathValue, equals: entry.equals }); + } + return normalized.length > 0 ? normalized : undefined; +} + +function normalizeManifestSecretInputPaths( + value: unknown, +): PluginManifestSecretInputPath[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const normalized: PluginManifestSecretInputPath[] = []; + for (const entry of value) { + if (!isRecord(entry)) { + continue; + } + const pathLocal = normalizeOptionalString(entry.path) ?? ""; + if (!pathLocal) { + continue; + } + const expected = entry.expected === "string" ? entry.expected : undefined; + const ownerKind = entry.ownerKind === "route" ? entry.ownerKind : undefined; + normalized.push({ + path: pathLocal, + ...(expected ? { expected } : {}), + ...(ownerKind ? { ownerKind } : {}), + }); + } + return normalized.length > 0 ? normalized : undefined; +} + +export function normalizeManifestConfigContracts( + value: unknown, +): PluginManifestConfigContracts | undefined { + if (!isRecord(value)) { + return undefined; + } + const compatibilityMigrationPaths = normalizeTrimmedStringList(value.compatibilityMigrationPaths); + const compatibilityRuntimePaths = normalizeTrimmedStringList(value.compatibilityRuntimePaths); + const rawSecretInputs = isRecord(value.secretInputs) ? value.secretInputs : undefined; + const dangerousFlags = normalizeManifestDangerousConfigFlags(value.dangerousFlags); + const secretInputPaths = rawSecretInputs + ? normalizeManifestSecretInputPaths(rawSecretInputs.paths) + : undefined; + const secretInputs = + secretInputPaths && secretInputPaths.length > 0 + ? ({ + ...(rawSecretInputs?.bundledDefaultEnabled === true + ? { bundledDefaultEnabled: true } + : rawSecretInputs?.bundledDefaultEnabled === false + ? { bundledDefaultEnabled: false } + : {}), + paths: secretInputPaths, + } satisfies PluginManifestSecretInputContracts) + : undefined; + const configContracts = { + ...(compatibilityMigrationPaths.length > 0 ? { compatibilityMigrationPaths } : {}), + ...(compatibilityRuntimePaths.length > 0 ? { compatibilityRuntimePaths } : {}), + ...(dangerousFlags ? { dangerousFlags } : {}), + ...(secretInputs ? { secretInputs } : {}), + } satisfies PluginManifestConfigContracts; + return Object.keys(configContracts).length > 0 ? configContracts : undefined; +} diff --git a/src/plugins/manifest-model-provider-normalizers.ts b/src/plugins/manifest-model-provider-normalizers.ts new file mode 100644 index 000000000000..6ae069fe9881 --- /dev/null +++ b/src/plugins/manifest-model-provider-normalizers.ts @@ -0,0 +1,365 @@ +import { normalizeModelCatalogProviderId } from "@openclaw/model-catalog-core/model-catalog-refs"; +import { normalizeOptionalString } from "../../packages/normalization-core/src/string-coerce.js"; +import { normalizeTrimmedStringList } from "../../packages/normalization-core/src/string-normalization.js"; +import { ENV_SECRET_REF_ID_RE } from "../config/types.secrets.js"; +import { isBlockedObjectKey } from "../infra/prototype-keys.js"; +import { isRecord } from "../utils.js"; +import { normalizeStringRecord } from "./manifest-capability-normalizers.js"; +import type { + PluginManifestModelIdNormalization, + PluginManifestModelIdNormalizationProvider, + PluginManifestModelIdPrefixRule, + PluginManifestModelPricing, + PluginManifestModelPricingModelIdTransform, + PluginManifestModelPricingProvider, + PluginManifestModelPricingSource, + PluginManifestModelSupport, + PluginManifestProviderEndpoint, + PluginManifestProviderRequest, + PluginManifestProviderRequestProvider, + PluginManifestSecretProviderIntegration, +} from "./manifest-types.js"; + +const MAX_SECRET_PROVIDER_EXEC_ARGS = 128; +const MAX_SECRET_PROVIDER_EXEC_ARG_BYTES = 1024; +const MAX_SECRET_PROVIDER_EXEC_TIMEOUT_MS = 120_000; +const MAX_SECRET_PROVIDER_EXEC_OUTPUT_BYTES = 20 * 1024 * 1024; +const MAX_SECRET_PROVIDER_EXEC_PASS_ENV = 128; +const SECRET_PROVIDER_NODE_COMMAND_PLACEHOLDER = "${node}"; + +export function normalizeManifestModelSupport( + value: unknown, +): PluginManifestModelSupport | undefined { + if (!isRecord(value)) { + return undefined; + } + + const modelPrefixes = normalizeTrimmedStringList(value.modelPrefixes); + const modelPatterns = normalizeTrimmedStringList(value.modelPatterns); + const modelSupport = { + ...(modelPrefixes.length > 0 ? { modelPrefixes } : {}), + ...(modelPatterns.length > 0 ? { modelPatterns } : {}), + } satisfies PluginManifestModelSupport; + + return Object.keys(modelSupport).length > 0 ? modelSupport : undefined; +} + +function normalizeManifestModelPricingSource( + value: unknown, +): PluginManifestModelPricingSource | false | undefined { + if (value === false) { + return false; + } + if (!isRecord(value)) { + return undefined; + } + const provider = normalizeModelCatalogProviderId(normalizeOptionalString(value.provider) ?? ""); + const modelIdTransforms = normalizeTrimmedStringList(value.modelIdTransforms).filter( + (entry): entry is PluginManifestModelPricingModelIdTransform => entry === "version-dots", + ); + const source = { + ...(provider ? { provider } : {}), + ...(value.passthroughProviderModel === true ? { passthroughProviderModel: true } : {}), + ...(modelIdTransforms.length > 0 ? { modelIdTransforms } : {}), + } satisfies PluginManifestModelPricingSource; + return Object.keys(source).length > 0 ? source : undefined; +} + +function normalizeManifestModelPricingProvider( + value: unknown, +): PluginManifestModelPricingProvider | undefined { + if (!isRecord(value)) { + return undefined; + } + const openRouter = normalizeManifestModelPricingSource(value.openRouter); + const liteLLM = normalizeManifestModelPricingSource(value.liteLLM); + const policy = { + ...(typeof value.external === "boolean" ? { external: value.external } : {}), + ...(openRouter !== undefined ? { openRouter } : {}), + ...(liteLLM !== undefined ? { liteLLM } : {}), + } satisfies PluginManifestModelPricingProvider; + return Object.keys(policy).length > 0 ? policy : undefined; +} + +function normalizeOwnedProviderMap( + value: unknown, + ownedProvidersRaw: ReadonlySet, + normalizePolicy: (value: unknown) => T | undefined, +): Record | undefined { + if (!isRecord(value) || !isRecord(value.providers)) { + return undefined; + } + const ownedProviders = new Set( + [...ownedProvidersRaw] + .map((provider) => normalizeModelCatalogProviderId(provider)) + .filter(Boolean), + ); + const providers: Record = {}; + for (const [rawProviderId, rawPolicy] of Object.entries(value.providers)) { + const providerId = normalizeModelCatalogProviderId(rawProviderId); + const policy = providerId && ownedProviders.has(providerId) ? normalizePolicy(rawPolicy) : null; + if (providerId && policy) { + providers[providerId] = policy; + } + } + return Object.keys(providers).length > 0 ? providers : undefined; +} + +export function normalizeManifestModelPricing( + value: unknown, + params: { ownedProviders: ReadonlySet }, +): PluginManifestModelPricing | undefined { + const providers = normalizeOwnedProviderMap( + value, + params.ownedProviders, + normalizeManifestModelPricingProvider, + ); + return providers ? { providers } : undefined; +} + +function normalizeManifestModelIdPrefixRules( + value: unknown, +): PluginManifestModelIdPrefixRule[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const rules: PluginManifestModelIdPrefixRule[] = []; + for (const rawRule of value) { + if (!isRecord(rawRule)) { + continue; + } + const modelPrefix = normalizeOptionalString(rawRule.modelPrefix); + const prefix = normalizeOptionalString(rawRule.prefix); + if (!modelPrefix || !prefix) { + continue; + } + rules.push({ modelPrefix, prefix }); + } + return rules.length > 0 ? rules : undefined; +} + +function normalizeManifestModelIdNormalizationProvider( + value: unknown, +): PluginManifestModelIdNormalizationProvider | undefined { + if (!isRecord(value)) { + return undefined; + } + + const aliases: Record = {}; + if (isRecord(value.aliases)) { + for (const [rawAlias, rawCanonical] of Object.entries(value.aliases)) { + const alias = normalizeModelCatalogProviderId(rawAlias); + const canonical = normalizeOptionalString(rawCanonical); + if (alias && canonical) { + aliases[alias] = canonical; + } + } + } + const stripPrefixes = normalizeTrimmedStringList(value.stripPrefixes); + const prefixWhenBare = normalizeOptionalString(value.prefixWhenBare); + const prefixWhenBareAfterAliasStartsWith = normalizeManifestModelIdPrefixRules( + value.prefixWhenBareAfterAliasStartsWith, + ); + const normalization = { + ...(Object.keys(aliases).length > 0 ? { aliases } : {}), + ...(stripPrefixes.length > 0 ? { stripPrefixes } : {}), + ...(prefixWhenBare ? { prefixWhenBare } : {}), + ...(prefixWhenBareAfterAliasStartsWith ? { prefixWhenBareAfterAliasStartsWith } : {}), + } satisfies PluginManifestModelIdNormalizationProvider; + + return Object.keys(normalization).length > 0 ? normalization : undefined; +} + +export function normalizeManifestModelIdNormalization( + value: unknown, + params: { ownedProviders: ReadonlySet }, +): PluginManifestModelIdNormalization | undefined { + const providers = normalizeOwnedProviderMap( + value, + params.ownedProviders, + normalizeManifestModelIdNormalizationProvider, + ); + return providers ? { providers } : undefined; +} + +export function normalizeManifestProviderEndpoints( + value: unknown, +): PluginManifestProviderEndpoint[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const endpoints: PluginManifestProviderEndpoint[] = []; + for (const rawEndpoint of value) { + if (!isRecord(rawEndpoint)) { + continue; + } + const endpointClass = normalizeOptionalString(rawEndpoint.endpointClass); + if (!endpointClass) { + continue; + } + const hosts = normalizeTrimmedStringList(rawEndpoint.hosts).map((host) => host.toLowerCase()); + const hostSuffixes = normalizeTrimmedStringList(rawEndpoint.hostSuffixes).map((host) => + host.toLowerCase(), + ); + const baseUrls = normalizeTrimmedStringList(rawEndpoint.baseUrls); + const googleVertexRegion = normalizeOptionalString(rawEndpoint.googleVertexRegion); + const googleVertexRegionHostSuffix = normalizeOptionalString( + rawEndpoint.googleVertexRegionHostSuffix, + )?.toLowerCase(); + if (hosts.length === 0 && hostSuffixes.length === 0 && baseUrls.length === 0) { + continue; + } + endpoints.push({ + endpointClass, + ...(hosts.length > 0 ? { hosts } : {}), + ...(hostSuffixes.length > 0 ? { hostSuffixes } : {}), + ...(baseUrls.length > 0 ? { baseUrls } : {}), + ...(googleVertexRegion ? { googleVertexRegion } : {}), + ...(googleVertexRegionHostSuffix ? { googleVertexRegionHostSuffix } : {}), + }); + } + + return endpoints.length > 0 ? endpoints : undefined; +} + +function normalizeManifestProviderRequestProvider( + value: unknown, +): PluginManifestProviderRequestProvider | undefined { + if (!isRecord(value)) { + return undefined; + } + const family = normalizeOptionalString(value.family); + const compatibilityFamily = + normalizeOptionalString(value.compatibilityFamily) === "moonshot" ? "moonshot" : undefined; + const supportsStreamingUsage = isRecord(value.openAICompletions) + ? value.openAICompletions.supportsStreamingUsage + : undefined; + const openAICompletions = + typeof supportsStreamingUsage === "boolean" ? { supportsStreamingUsage } : undefined; + const providerRequest = { + ...(family ? { family } : {}), + ...(compatibilityFamily ? { compatibilityFamily } : {}), + ...(openAICompletions && Object.keys(openAICompletions).length > 0 + ? { openAICompletions } + : {}), + } satisfies PluginManifestProviderRequestProvider; + return Object.keys(providerRequest).length > 0 ? providerRequest : undefined; +} + +export function normalizeManifestProviderRequest( + value: unknown, + params: { ownedProviders: ReadonlySet }, +): PluginManifestProviderRequest | undefined { + const providers = normalizeOwnedProviderMap( + value, + params.ownedProviders, + normalizeManifestProviderRequestProvider, + ); + return providers ? { providers } : undefined; +} + +function normalizeManifestStringArray( + value: unknown, + options?: { maxItems?: number; maxLength?: number; pattern?: RegExp }, +): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const normalized: string[] = []; + for (const entry of value) { + if (typeof entry !== "string") { + continue; + } + if (options?.maxLength !== undefined && entry.length > options.maxLength) { + continue; + } + if (options?.pattern && !options.pattern.test(entry)) { + continue; + } + normalized.push(entry); + if (options?.maxItems !== undefined && normalized.length >= options.maxItems) { + break; + } + } + return normalized.length > 0 ? normalized : undefined; +} + +function normalizeManifestTrimmedStringArray( + value: unknown, + options?: { maxItems?: number; pattern?: RegExp }, +): string[] | undefined { + const normalized = normalizeTrimmedStringList(value).filter( + (entry) => !options?.pattern || options.pattern.test(entry), + ); + const limited = + options?.maxItems !== undefined ? normalized.slice(0, options.maxItems) : normalized; + return limited.length > 0 ? limited : undefined; +} + +function normalizeManifestPositiveInteger(value: unknown, max: number): number | undefined { + return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= max + ? value + : undefined; +} + +export function normalizeManifestSecretProviderIntegrations( + value: unknown, +): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + const normalized: Record = Object.create(null); + for (const [rawId, rawIntegration] of Object.entries(value)) { + const id = normalizeOptionalString(rawId) ?? ""; + if (!id || isBlockedObjectKey(id) || !isRecord(rawIntegration)) { + continue; + } + const command = normalizeOptionalString(rawIntegration.command); + if (rawIntegration.source !== "exec" || command !== SECRET_PROVIDER_NODE_COMMAND_PLACEHOLDER) { + continue; + } + const providerAlias = normalizeOptionalString(rawIntegration.providerAlias); + const displayName = normalizeOptionalString(rawIntegration.displayName); + const description = normalizeOptionalString(rawIntegration.description); + const args = normalizeManifestStringArray(rawIntegration.args, { + maxItems: MAX_SECRET_PROVIDER_EXEC_ARGS, + maxLength: MAX_SECRET_PROVIDER_EXEC_ARG_BYTES, + }); + const timeoutMs = normalizeManifestPositiveInteger( + rawIntegration.timeoutMs, + MAX_SECRET_PROVIDER_EXEC_TIMEOUT_MS, + ); + const noOutputTimeoutMs = normalizeManifestPositiveInteger( + rawIntegration.noOutputTimeoutMs, + MAX_SECRET_PROVIDER_EXEC_TIMEOUT_MS, + ); + const maxOutputBytes = normalizeManifestPositiveInteger( + rawIntegration.maxOutputBytes, + MAX_SECRET_PROVIDER_EXEC_OUTPUT_BYTES, + ); + const env = normalizeStringRecord(rawIntegration.env); + const passEnv = normalizeManifestTrimmedStringArray(rawIntegration.passEnv, { + maxItems: MAX_SECRET_PROVIDER_EXEC_PASS_ENV, + pattern: ENV_SECRET_REF_ID_RE, + }); + normalized[id] = { + ...(providerAlias ? { providerAlias } : {}), + ...(displayName ? { displayName } : {}), + ...(description ? { description } : {}), + source: "exec", + command, + ...(args ? { args } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + ...(noOutputTimeoutMs !== undefined ? { noOutputTimeoutMs } : {}), + ...(maxOutputBytes !== undefined ? { maxOutputBytes } : {}), + ...(typeof rawIntegration.jsonOnly === "boolean" + ? { jsonOnly: rawIntegration.jsonOnly } + : {}), + ...(env ? { env } : {}), + ...(passEnv ? { passEnv } : {}), + ...(rawIntegration.allowInsecurePath === true ? { allowInsecurePath: true } : {}), + }; + } + return Object.keys(normalized).length > 0 ? normalized : undefined; +} diff --git a/src/plugins/manifest-setup-normalizers.ts b/src/plugins/manifest-setup-normalizers.ts new file mode 100644 index 000000000000..f42e04b49dcc --- /dev/null +++ b/src/plugins/manifest-setup-normalizers.ts @@ -0,0 +1,437 @@ +import { normalizeOptionalString } from "../../packages/normalization-core/src/string-coerce.js"; +import { normalizeTrimmedStringList } from "../../packages/normalization-core/src/string-normalization.js"; +import type { ChannelConfigRuntimeSchema } from "../channels/plugins/types.config.js"; +import { isBlockedObjectKey } from "../infra/prototype-keys.js"; +import type { JsonSchemaObject } from "../shared/json-schema.types.js"; +import { isRecord } from "../utils.js"; +import type { + PluginManifestActivation, + PluginManifestActivationCapability, + PluginManifestChannelCommandDefaults, + PluginManifestChannelConfig, + PluginManifestDashboard, + PluginManifestDashboardActionVerb, + PluginManifestDashboardDataBinding, + PluginManifestDefaultPlatform, + PluginManifestOnboardingScope, + PluginManifestProviderAuthChoice, + PluginManifestQaRunner, + PluginManifestSetup, + PluginManifestSetupProvider, + PluginManifestSetupProviderAuthEvidence, + PluginConfigUiHint, +} from "./manifest-types.js"; + +export function normalizeManifestActivation(value: unknown): PluginManifestActivation | undefined { + if (!isRecord(value)) { + return undefined; + } + + const onProviders = normalizeTrimmedStringList(value.onProviders); + const onAgentHarnesses = normalizeTrimmedStringList(value.onAgentHarnesses); + const onCommands = normalizeTrimmedStringList(value.onCommands); + const onChannels = normalizeTrimmedStringList(value.onChannels); + const onRoutes = normalizeTrimmedStringList(value.onRoutes); + const onConfigPaths = normalizeTrimmedStringList(value.onConfigPaths); + const onStartup = typeof value.onStartup === "boolean" ? value.onStartup : undefined; + const onCapabilities = normalizeTrimmedStringList(value.onCapabilities).filter( + (capability): capability is PluginManifestActivationCapability => + capability === "provider" || + capability === "channel" || + capability === "tool" || + capability === "hook", + ); + + const activation = { + ...(onStartup !== undefined ? { onStartup } : {}), + ...(onProviders.length > 0 ? { onProviders } : {}), + ...(onAgentHarnesses.length > 0 ? { onAgentHarnesses } : {}), + ...(onCommands.length > 0 ? { onCommands } : {}), + ...(onChannels.length > 0 ? { onChannels } : {}), + ...(onRoutes.length > 0 ? { onRoutes } : {}), + ...(onConfigPaths.length > 0 ? { onConfigPaths } : {}), + ...(onCapabilities.length > 0 ? { onCapabilities } : {}), + } satisfies PluginManifestActivation; + + return Object.keys(activation).length > 0 ? activation : undefined; +} + +const MANIFEST_DEFAULT_ENABLEMENT_PLATFORMS = new Set([ + "aix", + "android", + "darwin", + "freebsd", + "haiku", + "linux", + "openbsd", + "sunos", + "win32", + "cygwin", + "netbsd", +]); + +export function normalizeManifestDefaultPlatforms(value: unknown): PluginManifestDefaultPlatform[] { + return normalizeTrimmedStringList(value).filter( + (platform): platform is PluginManifestDefaultPlatform => + MANIFEST_DEFAULT_ENABLEMENT_PLATFORMS.has(platform as PluginManifestDefaultPlatform), + ); +} + +function normalizeManifestSetupProviders( + value: unknown, +): PluginManifestSetupProvider[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const normalized: PluginManifestSetupProvider[] = []; + for (const entry of value) { + if (!isRecord(entry)) { + continue; + } + const id = normalizeOptionalString(entry.id) ?? ""; + if (!id) { + continue; + } + const authMethods = normalizeTrimmedStringList(entry.authMethods); + const envVars = normalizeTrimmedStringList(entry.envVars); + const authEvidence = normalizeManifestSetupProviderAuthEvidence(entry.authEvidence); + normalized.push({ + id, + ...(authMethods.length > 0 ? { authMethods } : {}), + ...(envVars.length > 0 ? { envVars } : {}), + ...(authEvidence ? { authEvidence } : {}), + }); + } + return normalized.length > 0 ? normalized : undefined; +} + +function normalizeManifestSetupProviderAuthEvidence( + value: unknown, +): PluginManifestSetupProviderAuthEvidence[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const normalized: PluginManifestSetupProviderAuthEvidence[] = []; + for (const entry of value) { + if (!isRecord(entry) || entry.type !== "local-file-with-env") { + continue; + } + const credentialMarker = normalizeOptionalString(entry.credentialMarker); + if (!credentialMarker) { + continue; + } + const fileEnvVar = normalizeOptionalString(entry.fileEnvVar); + const fallbackPaths = normalizeTrimmedStringList(entry.fallbackPaths); + if (!fileEnvVar && fallbackPaths.length === 0) { + continue; + } + const requiresAnyEnv = normalizeTrimmedStringList(entry.requiresAnyEnv); + const requiresAllEnv = normalizeTrimmedStringList(entry.requiresAllEnv); + const source = normalizeOptionalString(entry.source); + normalized.push({ + type: "local-file-with-env", + ...(fileEnvVar ? { fileEnvVar } : {}), + ...(fallbackPaths.length > 0 ? { fallbackPaths } : {}), + ...(requiresAnyEnv.length > 0 ? { requiresAnyEnv } : {}), + ...(requiresAllEnv.length > 0 ? { requiresAllEnv } : {}), + credentialMarker, + ...(source ? { source } : {}), + }); + } + return normalized.length > 0 ? normalized : undefined; +} + +export function normalizeManifestSetup(value: unknown): PluginManifestSetup | undefined { + if (!isRecord(value)) { + return undefined; + } + const providers = normalizeManifestSetupProviders(value.providers); + const cliBackends = normalizeTrimmedStringList(value.cliBackends); + const configMigrations = normalizeTrimmedStringList(value.configMigrations); + const requiresRuntime = + typeof value.requiresRuntime === "boolean" ? value.requiresRuntime : undefined; + const setup = { + ...(providers ? { providers } : {}), + ...(cliBackends.length > 0 ? { cliBackends } : {}), + ...(configMigrations.length > 0 ? { configMigrations } : {}), + ...(requiresRuntime !== undefined ? { requiresRuntime } : {}), + } satisfies PluginManifestSetup; + return Object.keys(setup).length > 0 ? setup : undefined; +} + +export function normalizeManifestQaRunners(value: unknown): PluginManifestQaRunner[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const normalized: PluginManifestQaRunner[] = []; + for (const entry of value) { + if (!isRecord(entry)) { + continue; + } + const commandName = normalizeOptionalString(entry.commandName) ?? ""; + if (!commandName) { + continue; + } + const description = normalizeOptionalString(entry.description) ?? ""; + normalized.push({ + commandName, + ...(description ? { description } : {}), + }); + } + return normalized.length > 0 ? normalized : undefined; +} + +type DashboardManifestResult = + | { ok: true; dashboard?: PluginManifestDashboard } + | { ok: false; error: string }; + +function normalizeDashboardCapabilityBase( + value: unknown, + field: string, + index: number, +): { id: string; method: string; description: string } | string { + if (!isRecord(value)) { + return `${field}[${index}] must be an object`; + } + const id = normalizeOptionalString(value.id); + const method = normalizeOptionalString(value.method); + const description = normalizeOptionalString(value.description); + if (!id || !/^[a-z0-9][a-z0-9._-]*$/u.test(id)) { + return `${field}[${index}].id must be a lowercase capability id`; + } + if (!method) { + return `${field}[${index}].method must be a non-empty string`; + } + if (!description) { + return `${field}[${index}].description must be a non-empty string`; + } + return { id, method, description }; +} + +export function normalizeManifestDashboard(value: unknown): DashboardManifestResult { + if (value === undefined) { + return { ok: true }; + } + if (!isRecord(value)) { + return { ok: false, error: "dashboard must be an object" }; + } + if (value.dataBindings !== undefined && !Array.isArray(value.dataBindings)) { + return { ok: false, error: "dashboard.dataBindings must be an array" }; + } + if (value.actionVerbs !== undefined && !Array.isArray(value.actionVerbs)) { + return { ok: false, error: "dashboard.actionVerbs must be an array" }; + } + + const dataBindings: PluginManifestDashboardDataBinding[] = []; + for (const [index, entry] of (value.dataBindings ?? []).entries()) { + const normalized = normalizeDashboardCapabilityBase(entry, "dashboard.dataBindings", index); + if (typeof normalized === "string") { + return { ok: false, error: normalized }; + } + dataBindings.push(normalized); + } + + const actionVerbs: PluginManifestDashboardActionVerb[] = []; + for (const [index, entry] of (value.actionVerbs ?? []).entries()) { + const normalized = normalizeDashboardCapabilityBase(entry, "dashboard.actionVerbs", index); + if (typeof normalized === "string") { + return { ok: false, error: normalized }; + } + const rawParamShape = isRecord(entry) ? entry.paramShape : undefined; + if (rawParamShape !== undefined && !isRecord(rawParamShape)) { + return { + ok: false, + error: `dashboard.actionVerbs[${index}].paramShape must be a JSON Schema object`, + }; + } + actionVerbs.push({ + ...normalized, + ...(rawParamShape ? { paramShape: rawParamShape as JsonSchemaObject } : {}), + }); + } + + if (dataBindings.length === 0 && actionVerbs.length === 0) { + return { ok: true }; + } + return { + ok: true, + dashboard: { + ...(dataBindings.length > 0 ? { dataBindings } : {}), + ...(actionVerbs.length > 0 ? { actionVerbs } : {}), + }, + }; +} + +function normalizeManifestHttpsUrl(value: unknown): string | undefined { + const normalized = normalizeOptionalString(value); + if (!normalized) { + return undefined; + } + try { + const url = new URL(normalized); + const canonical = url.toString(); + return url.protocol === "https:" && + url.hostname && + !url.username && + !url.password && + canonical.length <= 2048 + ? canonical + : undefined; + } catch { + return undefined; + } +} + +export function normalizeProviderAuthChoices( + value: unknown, +): PluginManifestProviderAuthChoice[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const normalized: PluginManifestProviderAuthChoice[] = []; + for (const entry of value) { + if (!isRecord(entry)) { + continue; + } + const provider = normalizeOptionalString(entry.provider) ?? ""; + const method = normalizeOptionalString(entry.method) ?? ""; + const choiceId = normalizeOptionalString(entry.choiceId) ?? ""; + if (!provider || !method || !choiceId) { + continue; + } + const choiceLabel = normalizeOptionalString(entry.choiceLabel) ?? ""; + const choiceHint = normalizeOptionalString(entry.choiceHint) ?? ""; + const icon = normalizeManifestHttpsUrl(entry.icon); + const website = normalizeManifestHttpsUrl(entry.website); + const assistantPriority = + typeof entry.assistantPriority === "number" && Number.isFinite(entry.assistantPriority) + ? entry.assistantPriority + : undefined; + const assistantVisibility = + entry.assistantVisibility === "manual-only" || entry.assistantVisibility === "visible" + ? entry.assistantVisibility + : undefined; + const deprecatedChoiceIds = normalizeTrimmedStringList(entry.deprecatedChoiceIds); + const groupId = normalizeOptionalString(entry.groupId) ?? ""; + const groupLabel = normalizeOptionalString(entry.groupLabel) ?? ""; + const groupHint = normalizeOptionalString(entry.groupHint) ?? ""; + const onboardingFeatured = entry.onboardingFeatured === true; + const optionKey = normalizeOptionalString(entry.optionKey) ?? ""; + const cliFlag = normalizeOptionalString(entry.cliFlag) ?? ""; + const cliOption = normalizeOptionalString(entry.cliOption) ?? ""; + const cliDescription = normalizeOptionalString(entry.cliDescription) ?? ""; + const appGuidedSecret = entry.appGuidedSecret === true; + const appGuidedAuth = + entry.appGuidedAuth === "oauth" || entry.appGuidedAuth === "device-code" + ? entry.appGuidedAuth + : undefined; + const onboardingScopes = normalizeTrimmedStringList(entry.onboardingScopes).filter( + (scope): scope is PluginManifestOnboardingScope => + scope === "text-inference" || scope === "image-generation" || scope === "music-generation", + ); + const appGuidedDiscovery = entry.appGuidedDiscovery === true; + normalized.push({ + provider, + method, + choiceId, + ...(choiceLabel ? { choiceLabel } : {}), + ...(choiceHint ? { choiceHint } : {}), + ...(icon ? { icon } : {}), + ...(website ? { website } : {}), + ...(assistantPriority !== undefined ? { assistantPriority } : {}), + ...(assistantVisibility ? { assistantVisibility } : {}), + ...(deprecatedChoiceIds.length > 0 ? { deprecatedChoiceIds } : {}), + ...(groupId ? { groupId } : {}), + ...(groupLabel ? { groupLabel } : {}), + ...(groupHint ? { groupHint } : {}), + ...(onboardingFeatured ? { onboardingFeatured: true } : {}), + ...(appGuidedDiscovery ? { appGuidedDiscovery: true } : {}), + ...(optionKey ? { optionKey } : {}), + ...(cliFlag ? { cliFlag } : {}), + ...(cliOption ? { cliOption } : {}), + ...(cliDescription ? { cliDescription } : {}), + ...(appGuidedSecret ? { appGuidedSecret: true } : {}), + ...(appGuidedAuth ? { appGuidedAuth } : {}), + ...(onboardingScopes.length > 0 ? { onboardingScopes } : {}), + }); + } + return normalized.length > 0 ? normalized : undefined; +} + +export function normalizeConfigUiHints( + value: unknown, +): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + const normalized: Record = Object.create(null); + for (const [hintPath, rawHint] of Object.entries(value)) { + if (!isRecord(rawHint)) { + continue; + } + const hint = { ...rawHint } as Record; + if ("presentation" in hint && hint.presentation !== "phone-number") { + delete hint.presentation; + } + normalized[hintPath] = hint as PluginConfigUiHint; + } + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +export function normalizeChannelConfigs( + value: unknown, +): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + const normalized: Record = Object.create(null); + for (const [key, rawEntry] of Object.entries(value)) { + const channelId = normalizeOptionalString(key) ?? ""; + if (!channelId || isBlockedObjectKey(channelId) || !isRecord(rawEntry)) { + continue; + } + const schema = isRecord(rawEntry.schema) ? rawEntry.schema : null; + if (!schema) { + continue; + } + const uiHints = normalizeConfigUiHints(rawEntry.uiHints); + const runtime = + isRecord(rawEntry.runtime) && typeof rawEntry.runtime.safeParse === "function" + ? (rawEntry.runtime as ChannelConfigRuntimeSchema) + : undefined; + const label = normalizeOptionalString(rawEntry.label) ?? ""; + const description = normalizeOptionalString(rawEntry.description) ?? ""; + const preferOver = normalizeTrimmedStringList(rawEntry.preferOver); + const commandDefaults = normalizeManifestChannelCommandDefaults(rawEntry.commands); + normalized[channelId] = { + schema, + ...(uiHints ? { uiHints } : {}), + ...(runtime ? { runtime } : {}), + ...(label ? { label } : {}), + ...(description ? { description } : {}), + ...(preferOver.length > 0 ? { preferOver } : {}), + ...(commandDefaults ? { commands: commandDefaults } : {}), + }; + } + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +export function normalizeManifestChannelCommandDefaults( + value: unknown, +): PluginManifestChannelCommandDefaults | undefined { + if (!isRecord(value)) { + return undefined; + } + const nativeCommandsAutoEnabled = + typeof value.nativeCommandsAutoEnabled === "boolean" + ? value.nativeCommandsAutoEnabled + : undefined; + const nativeSkillsAutoEnabled = + typeof value.nativeSkillsAutoEnabled === "boolean" ? value.nativeSkillsAutoEnabled : undefined; + return nativeCommandsAutoEnabled !== undefined || nativeSkillsAutoEnabled !== undefined + ? { + ...(nativeCommandsAutoEnabled !== undefined ? { nativeCommandsAutoEnabled } : {}), + ...(nativeSkillsAutoEnabled !== undefined ? { nativeSkillsAutoEnabled } : {}), + } + : undefined; +} diff --git a/src/plugins/manifest-types.ts b/src/plugins/manifest-types.ts index 6dc1e75ca13c..1c65598e41d5 100644 --- a/src/plugins/manifest-types.ts +++ b/src/plugins/manifest-types.ts @@ -1,4 +1,9 @@ +import type { ModelCatalog } from "@openclaw/model-catalog-core/model-catalog-types"; +import type { ChannelConfigRuntimeSchema } from "../channels/plugins/types.config.js"; import type { ConfigUiPresentation } from "../shared/config-ui-hints-types.js"; +import type { JsonSchemaObject } from "../shared/json-schema.types.js"; +import type { PluginManifestCommandAlias } from "./manifest-command-aliases.js"; +import type { PluginKind } from "./plugin-kind.types.js"; /** UI hint metadata for plugin config schema fields. */ export type PluginConfigUiHint = { @@ -34,3 +39,529 @@ export type PluginDiagnostic = { source?: string; code?: PluginDiagnosticCode; }; + +export type PluginManifestChannelConfig = { + schema: JsonSchemaObject; + uiHints?: Record; + runtime?: ChannelConfigRuntimeSchema; + label?: string; + description?: string; + preferOver?: string[]; + commands?: PluginManifestChannelCommandDefaults; +}; + +export type PluginManifestChannelCommandDefaults = { + nativeCommandsAutoEnabled?: boolean; + nativeSkillsAutoEnabled?: boolean; +}; + +export type PluginManifestModelSupport = { + /** + * Cheap manifest-owned model-id prefixes for transparent provider activation + * from shorthand model refs such as `gpt-5.4` or `claude-sonnet-4.6`. + */ + modelPrefixes?: string[]; + /** + * Regex sources matched against the raw model id after profile suffixes are + * stripped. Use this when simple prefixes are not expressive enough. + */ + modelPatterns?: string[]; +}; + +export type PluginManifestModelCatalog = ModelCatalog; + +export type PluginManifestModelPricingModelIdTransform = "version-dots"; + +export type PluginManifestModelPricingSource = { + provider?: string; + passthroughProviderModel?: boolean; + modelIdTransforms?: PluginManifestModelPricingModelIdTransform[]; +}; + +export type PluginManifestModelPricingProvider = { + external?: boolean; + openRouter?: PluginManifestModelPricingSource | false; + liteLLM?: PluginManifestModelPricingSource | false; +}; + +export type PluginManifestModelPricing = { + providers?: Record; +}; + +export type PluginManifestModelIdPrefixRule = { + modelPrefix: string; + prefix: string; +}; + +export type PluginManifestModelIdNormalizationProvider = { + aliases?: Record; + stripPrefixes?: string[]; + prefixWhenBare?: string; + prefixWhenBareAfterAliasStartsWith?: PluginManifestModelIdPrefixRule[]; +}; + +export type PluginManifestModelIdNormalization = { + providers?: Record; +}; + +export type PluginManifestProviderEndpoint = { + /** + * Core endpoint class this plugin-owned endpoint should map to. Core must + * already know the class; manifests own host/baseUrl matching metadata. + */ + endpointClass: string; + /** Hostnames that should resolve to this endpoint class. */ + hosts?: string[]; + /** Host suffixes that should resolve to this endpoint class. */ + hostSuffixes?: string[]; + /** Exact normalized base URLs that should resolve to this endpoint class. */ + baseUrls?: string[]; + /** Static Google Vertex region metadata for exact global hosts. */ + googleVertexRegion?: string; + /** Host suffix whose prefix should be exposed as the Google Vertex region. */ + googleVertexRegionHostSuffix?: string; +}; + +export type PluginManifestProviderRequestProvider = { + family?: string; + compatibilityFamily?: "moonshot"; + openAICompletions?: { + supportsStreamingUsage?: boolean; + }; +}; + +export type PluginManifestProviderRequest = { + providers?: Record; +}; + +export type PluginManifestSecretProviderIntegration = { + providerAlias?: string; + displayName?: string; + description?: string; + source: "exec"; + command: "${node}"; + args?: string[]; + timeoutMs?: number; + noOutputTimeoutMs?: number; + maxOutputBytes?: number; + jsonOnly?: boolean; + env?: Record; + passEnv?: string[]; + allowInsecurePath?: boolean; +}; + +export type PluginManifestActivationCapability = "provider" | "channel" | "tool" | "hook"; + +export type PluginManifestActivation = { + /** + * Explicit Gateway startup activation. Set true when the plugin must be + * imported during Gateway startup; set false when narrower activation + * triggers should load it on demand. + */ + onStartup?: boolean; + /** + * Provider ids that should include this plugin in activation/load plans. + * This is planner metadata only; runtime behavior still comes from register(). + */ + onProviders?: string[]; + /** Agent harness runtime ids that should include this plugin in activation/load plans. */ + onAgentHarnesses?: string[]; + /** Command ids that should include this plugin in activation/load plans. */ + onCommands?: string[]; + /** Channel ids that should include this plugin in activation/load plans. */ + onChannels?: string[]; + /** Route kinds that should include this plugin in activation/load plans. */ + onRoutes?: string[]; + /** Root-relative config paths that should include this plugin in startup/load plans. */ + onConfigPaths?: string[]; + /** Broad capability hints for activation/load plans. Prefer narrower ownership metadata. */ + onCapabilities?: PluginManifestActivationCapability[]; +}; + +export type PluginManifestDefaultPlatform = NodeJS.Platform; + +export type PluginManifestSetupProvider = { + /** Provider id surfaced during setup/onboarding. */ + id: string; + /** Setup/auth methods that this provider supports. */ + authMethods?: string[]; + /** Environment variables that can satisfy setup without runtime loading. */ + envVars?: string[]; + /** + * Cheap local evidence that a provider can authenticate without loading + * runtime code. Evidence checks must not read secrets, shell out, or call + * provider APIs. + */ + authEvidence?: PluginManifestSetupProviderAuthEvidence[]; +}; + +export type PluginManifestSetupProviderAuthEvidence = { + /** Generic local file evidence gated by required environment metadata. */ + type: "local-file-with-env"; + /** Optional env var containing an explicit credential file path. */ + fileEnvVar?: string; + /** Optional fallback credential file paths. Supports `${HOME}` and `${APPDATA}`. */ + fallbackPaths?: string[]; + /** At least one of these env vars must be non-empty when provided. */ + requiresAnyEnv?: string[]; + /** Every env var listed here must be non-empty when provided. */ + requiresAllEnv?: string[]; + /** Non-secret marker returned when this evidence is present. */ + credentialMarker: string; + /** Human-readable auth source label. */ + source?: string; +}; + +export type PluginManifestSetup = { + /** Cheap provider setup metadata exposed before runtime loads. */ + providers?: PluginManifestSetupProvider[]; + /** Setup-time backend ids available without full runtime activation. */ + cliBackends?: string[]; + /** Config migration ids owned by this plugin's setup surface. */ + configMigrations?: string[]; + /** + * Whether setup still needs plugin runtime execution after descriptor lookup. + * Defaults to false when omitted. + */ + requiresRuntime?: boolean; +}; + +export type PluginManifestQaRunner = { + /** Subcommand mounted beneath `openclaw qa`, for example `matrix`. */ + commandName: string; + /** Optional user-facing help text for fallback host stubs. */ + description?: string; +}; + +export type PluginManifestDashboardDataBinding = { + /** Plugin-local id. Widget grants receive the plugin-id prefix. */ + id: string; + /** Read-scoped Gateway method registered by this plugin. */ + method: string; + description: string; +}; + +export type PluginManifestDashboardActionVerb = { + /** Plugin-local id. Widget grants receive the plugin-id prefix. */ + id: string; + /** Write-scoped Gateway method registered by this plugin. */ + method: string; + description: string; + /** Optional JSON Schema for the action params object. */ + paramShape?: JsonSchemaObject; +}; + +export type PluginManifestDashboard = { + dataBindings?: PluginManifestDashboardDataBinding[]; + actionVerbs?: PluginManifestDashboardActionVerb[]; +}; + +export type PluginManifestMcpServer = Record; + +export type PluginManifestConfigLiteral = string | number | boolean | null; + +export type PluginManifestDangerousConfigFlag = { + /** + * Dot-separated config path relative to `plugins.entries..config`. + * Supports `*` wildcards for map/array segments. + */ + path: string; + /** Exact literal that marks this config value as dangerous. */ + equals: PluginManifestConfigLiteral; +}; + +export type PluginManifestSecretInputPath = { + /** + * Dot-separated config path relative to `plugins.entries..config`. + * Supports `*` wildcards for map/array segments. + */ + path: string; + /** Expected resolved type for SecretRef materialization. */ + expected?: "string"; + /** Runtime owner kind used to isolate this surface when resolution fails. */ + ownerKind?: "route"; +}; + +export type PluginManifestSecretInputContracts = { + /** + * Override bundled-plugin default enablement when deciding whether this + * SecretRef surface is active. Use this when the plugin is bundled but the + * surface should stay inactive until explicitly enabled in config. + */ + bundledDefaultEnabled?: boolean; + paths: PluginManifestSecretInputPath[]; +}; + +export type PluginManifestConfigContracts = { + /** + * Root-relative config paths that indicate this plugin's setup-time + * compatibility migrations might apply. Use this to keep generic runtime + * config reads from loading every plugin setup surface when the config does + * not reference the plugin at all. + */ + compatibilityMigrationPaths?: string[]; + /** + * Root-relative compatibility paths that this plugin can service during + * runtime before plugin code fully activates. Use this for legacy surfaces + * that should cheaply narrow bundled candidate sets without importing every + * compatible plugin runtime. + */ + compatibilityRuntimePaths?: string[]; + dangerousFlags?: PluginManifestDangerousConfigFlag[]; + secretInputs?: PluginManifestSecretInputContracts; +}; + +export type PluginManifestCatalog = { + featured?: boolean; + order?: number; +}; + +export type PluginManifest = { + id: string; + configSchema: JsonSchemaObject; + /** Plugin ids that must also be installed for this plugin to have effect. */ + requiresPlugins?: string[]; + enabledByDefault?: boolean; + enabledByDefaultOnPlatforms?: PluginManifestDefaultPlatform[]; + /** Legacy plugin ids that should normalize to this plugin id. */ + legacyPluginIds?: string[]; + /** Provider ids that should auto-enable this plugin when referenced in auth/config/models. */ + autoEnableWhenConfiguredProviders?: string[]; + kind?: PluginKind | PluginKind[]; + channels?: string[]; + providers?: string[]; + /** + * Optional lightweight module that exports provider plugin metadata for + * auth/catalog discovery. It should not import the full plugin runtime. + */ + providerCatalogEntry?: string; + /** + * Cheap model-family ownership metadata used before plugin runtime loads. + * Use this for shorthand model refs that omit an explicit provider prefix. + */ + modelSupport?: PluginManifestModelSupport; + /** + * Declarative model catalog metadata used by future read-only listing, + * onboarding, and model picker surfaces before provider runtime loads. + */ + modelCatalog?: PluginManifestModelCatalog; + /** Manifest-owned external pricing lookup policy for provider refs. */ + modelPricing?: PluginManifestModelPricing; + /** Manifest-owned model-id normalization used before provider runtime loads. */ + modelIdNormalization?: PluginManifestModelIdNormalization; + /** Cheap provider endpoint metadata used before provider runtime loads. */ + providerEndpoints?: PluginManifestProviderEndpoint[]; + /** Cheap provider request metadata used before provider runtime loads. */ + providerRequest?: PluginManifestProviderRequest; + /** Declarative SecretRef provider presets owned by this plugin. */ + secretProviderIntegrations?: Record; + /** Cheap startup activation lookup for plugin-owned CLI inference backends. */ + cliBackends?: string[]; + /** + * Provider or CLI backend refs whose plugin-owned synthetic auth hook should + * be probed during cold model discovery before the runtime registry exists. + */ + syntheticAuthRefs?: string[]; + /** + * Bundled-plugin-owned placeholder API key values that represent non-secret + * local, OAuth, or ambient credential state. + */ + nonSecretAuthMarkers?: string[]; + /** + * Plugin-owned command aliases that should resolve to this plugin during + * config diagnostics before runtime loads. + */ + commandAliases?: PluginManifestCommandAlias[]; + /** Usage/billing credentials excluded from inference auth but included in secret scrubbing. */ + providerUsageAuthEnvVars?: Record; + /** Provider ids that should reuse another provider id for auth lookup. */ + providerAuthAliases?: Record; + /** + * Cheap onboarding/auth-choice metadata used by config validation, CLI help, + * and non-runtime auth-choice routing before provider runtime loads. + */ + providerAuthChoices?: PluginManifestProviderAuthChoice[]; + /** Cheap activation planner metadata exposed before plugin runtime loads. */ + activation?: PluginManifestActivation; + /** Cheap setup/onboarding metadata exposed before plugin runtime loads. */ + setup?: PluginManifestSetup; + /** Cheap QA runner metadata exposed before plugin runtime loads. */ + qaRunners?: PluginManifestQaRunner[]; + /** Widget data and action capabilities validated against runtime registrations. */ + dashboard?: PluginManifestDashboard; + /** Static MCP servers contributed while this plugin is enabled. */ + mcpServers?: Record; + skills?: string[]; + name?: string; + description?: string; + /** Optional presentation hints for plugin catalog surfaces. */ + catalog?: PluginManifestCatalog; + /** Optional HTTPS URL for marketplace/catalog card artwork. */ + icon?: string; + version?: string; + uiHints?: Record; + /** + * Static capability ownership snapshot used for manifest-driven discovery, + * compat wiring, and contract coverage without importing plugin runtime. + */ + contracts?: PluginManifestContracts; + /** Cheap media-understanding provider defaults without importing plugin runtime. */ + mediaUnderstandingProviderMetadata?: Record< + string, + PluginManifestMediaUnderstandingProviderMetadata + >; + /** Cheap image-generation provider auth metadata without importing plugin runtime. */ + imageGenerationProviderMetadata?: Record; + /** Cheap video-generation provider auth metadata without importing plugin runtime. */ + videoGenerationProviderMetadata?: Record; + /** Cheap music-generation provider auth metadata without importing plugin runtime. */ + musicGenerationProviderMetadata?: Record; + /** Cheap plugin-tool availability metadata without importing plugin runtime. */ + toolMetadata?: Record; + /** Manifest-owned config behavior consumed by generic core helpers. */ + configContracts?: PluginManifestConfigContracts; + channelConfigs?: Record; +}; + +export type PluginManifestContracts = { + embeddedExtensionFactories?: string[]; + agentToolResultMiddleware?: string[]; + trustedToolPolicies?: string[]; + /** + * Provider ids whose external auth profile hook can contribute runtime-only + * credentials. Declaring this lets auth-store overlays load only the owning + * plugin instead of every provider plugin. + */ + externalAuthProviders?: string[]; + embeddingProviders?: string[]; + memoryEmbeddingProviders?: string[]; + speechProviders?: string[]; + realtimeTranscriptionProviders?: string[]; + realtimeVoiceProviders?: string[]; + mediaUnderstandingProviders?: string[]; + transcriptSourceProviders?: string[]; + documentExtractors?: string[]; + imageGenerationProviders?: string[]; + videoGenerationProviders?: string[]; + musicGenerationProviders?: string[]; + webContentExtractors?: string[]; + webFetchProviders?: string[]; + webSearchProviders?: string[]; + workerProviders?: string[]; + /** Provider ids whose plugin owns usage auth and snapshot hooks. */ + usageProviders?: string[]; + migrationProviders?: string[]; + gatewayMethodDispatch?: string[]; + tools?: string[]; +}; + +export type PluginManifestMediaUnderstandingCapability = "image" | "audio" | "video"; + +export type PluginManifestMediaUnderstandingProviderMetadata = { + capabilities?: PluginManifestMediaUnderstandingCapability[]; + defaultModels?: Partial>; + autoPriority?: Partial>; + nativeDocumentInputs?: Array<"pdf">; + documentModels?: Partial< + Record< + "pdf", + { + textExtraction?: string; + image?: string | false; + } + > + >; +}; + +export type PluginManifestProviderBaseUrlGuard = { + provider: string; + defaultBaseUrl?: string; + allowedBaseUrls: string[]; +}; + +export type PluginManifestCapabilityProviderAuthSignal = { + provider: string; + providerBaseUrl?: PluginManifestProviderBaseUrlGuard; +}; + +export type PluginManifestCapabilityProviderModeConfigSignal = { + path?: string; + default?: string; + allowed?: string[]; + disallowed?: string[]; +}; + +export type PluginManifestCapabilityProviderConfigSignal = { + rootPath: string; + overlayPath?: string; + overlayMapPath?: string; + required?: string[]; + requiredAny?: string[]; + mode?: PluginManifestCapabilityProviderModeConfigSignal; +}; + +export type PluginManifestCapabilityProviderMetadata = { + aliases?: string[]; + authProviders?: string[]; + authSignals?: PluginManifestCapabilityProviderAuthSignal[]; + configSignals?: PluginManifestCapabilityProviderConfigSignal[]; + referenceAudioInputs?: boolean; +}; + +export type PluginManifestToolMetadata = PluginManifestCapabilityProviderMetadata & { + optional?: boolean; + /** Tool execution is safe to repeat after an incomplete model turn. */ + replaySafe?: boolean; +}; + +export type PluginManifestProviderAuthChoice = { + /** Provider id owned by this manifest entry. */ + provider: string; + /** Provider auth method id that this choice should dispatch to. */ + method: string; + /** Stable auth-choice id used by onboarding and other CLI auth flows. */ + choiceId: string; + /** Optional user-facing choice label/hint for grouped onboarding UI. */ + choiceLabel?: string; + choiceHint?: string; + /** Optional HTTPS artwork URL for native and web onboarding surfaces. */ + icon?: string; + /** Optional HTTPS product or installation URL for onboarding surfaces. */ + website?: string; + /** Lower values sort earlier in interactive assistant pickers. */ + assistantPriority?: number; + /** Keep the choice out of interactive assistant pickers while preserving manual CLI support. */ + assistantVisibility?: "visible" | "manual-only"; + /** Legacy choice ids that should point users at this replacement choice. */ + deprecatedChoiceIds?: string[]; + /** Optional grouping metadata for auth-choice pickers. */ + groupId?: string; + groupLabel?: string; + groupHint?: string; + /** + * Surface this group in the featured tier of the interactive onboarding + * picker. Featured groups appear before the "More…" entry. + */ + onboardingFeatured?: boolean; + /** Optional CLI flag metadata for one-flag auth flows such as API keys. */ + optionKey?: string; + cliFlag?: string; + cliOption?: string; + cliDescription?: string; + /** One pasted secret plus provider defaults is sufficient for app-guided setup. */ + appGuidedSecret?: boolean; + /** Provider-owned interactive login that native setup clients can render generically. */ + appGuidedAuth?: "oauth" | "device-code"; + /** + * Interactive onboarding surfaces where this auth choice should appear. + * Defaults to `["text-inference"]` when omitted. + */ + onboardingScopes?: PluginManifestOnboardingScope[]; + /** Provider runtime can discover and prepare an already-installed local model. */ + appGuidedDiscovery?: boolean; +}; + +export type PluginManifestOnboardingScope = + | "text-inference" + | "image-generation" + | "music-generation"; diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index eb662e46c2f9..46748dd37d63 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -2,44 +2,42 @@ import fs from "node:fs"; import path from "node:path"; import { normalizeModelCatalog } from "@openclaw/model-catalog-core/model-catalog-normalize"; -import { normalizeModelCatalogProviderId } from "@openclaw/model-catalog-core/model-catalog-refs"; -import type { ModelCatalog } from "@openclaw/model-catalog-core/model-catalog-types"; import { normalizeOptionalString } from "../../packages/normalization-core/src/string-coerce.js"; import { normalizeTrimmedStringList } from "../../packages/normalization-core/src/string-normalization.js"; -import type { ChannelConfigRuntimeSchema } from "../channels/plugins/types.config.js"; -import { MANIFEST_KEY } from "../compat/legacy-names.js"; -import { ENV_SECRET_REF_ID_RE } from "../config/types.secrets.js"; import { matchRootFileOpenFailure, openRootFileSync } from "../infra/boundary-file-read.js"; -import { isBlockedObjectKey } from "../infra/prototype-keys.js"; -import type { JsonSchemaObject } from "../shared/json-schema.types.js"; import { isRecord } from "../utils.js"; import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js"; -import { - normalizeManifestCommandAliases, - type PluginManifestCommandAlias, -} from "./manifest-command-aliases.js"; -import type { PluginConfigUiHint } from "./manifest-types.js"; +import * as capabilityNormalizers from "./manifest-capability-normalizers.js"; +import { normalizeManifestCommandAliases } from "./manifest-command-aliases.js"; +import * as modelProviderNormalizers from "./manifest-model-provider-normalizers.js"; +import * as setupNormalizers from "./manifest-setup-normalizers.js"; +import type { PluginManifest } from "./manifest-types.js"; import { createPluginCacheKey, PluginLruCache } from "./plugin-cache-primitives.js"; import type { PluginKind } from "./plugin-kind.types.js"; import { normalizePluginPolicyId } from "./plugin-policy-id.js"; +export type * from "./manifest-types.js"; +export * from "./package-manifest.js"; +export { + normalizeManifestActivation, + normalizeManifestChannelCommandDefaults, +} from "./manifest-setup-normalizers.js"; + /** Canonical plugin manifest filename inside plugin roots. */ export const PLUGIN_MANIFEST_FILENAME = "openclaw.plugin.json"; const PLUGIN_MANIFEST_FILENAMES = [PLUGIN_MANIFEST_FILENAME] as const; const MAX_PLUGIN_MANIFEST_BYTES = 256 * 1024; const MAX_PLUGIN_MANIFEST_LOAD_CACHE_ENTRIES = 512; -const MAX_SECRET_PROVIDER_EXEC_ARGS = 128; -const MAX_SECRET_PROVIDER_EXEC_ARG_BYTES = 1024; -const MAX_SECRET_PROVIDER_EXEC_TIMEOUT_MS = 120_000; -const MAX_SECRET_PROVIDER_EXEC_OUTPUT_BYTES = 20 * 1024 * 1024; -const MAX_SECRET_PROVIDER_EXEC_PASS_ENV = 128; -const SECRET_PROVIDER_NODE_COMMAND_PLACEHOLDER = "${node}"; const CORE_RESERVED_PLUGIN_IDS = new Set(["node-mcp"]); export function isCoreReservedPluginId(id: string): boolean { return CORE_RESERVED_PLUGIN_IDS.has(normalizePluginPolicyId(id)); } +type PluginManifestLoadResult = + | { ok: true; manifest: PluginManifest; manifestPath: string } + | { ok: false; error: string; manifestPath: string }; + type PluginManifestLoadCacheEntry = { result: PluginManifestLoadResult; size: number; @@ -50,1781 +48,6 @@ type PluginManifestLoadCacheEntry = { const pluginManifestLoadCache = new PluginLruCache( MAX_PLUGIN_MANIFEST_LOAD_CACHE_ENTRIES, ); -export type PluginManifestChannelConfig = { - schema: JsonSchemaObject; - uiHints?: Record; - runtime?: ChannelConfigRuntimeSchema; - label?: string; - description?: string; - preferOver?: string[]; - commands?: PluginManifestChannelCommandDefaults; -}; - -export type PluginManifestChannelCommandDefaults = { - nativeCommandsAutoEnabled?: boolean; - nativeSkillsAutoEnabled?: boolean; -}; - -export type PluginManifestModelSupport = { - /** - * Cheap manifest-owned model-id prefixes for transparent provider activation - * from shorthand model refs such as `gpt-5.4` or `claude-sonnet-4.6`. - */ - modelPrefixes?: string[]; - /** - * Regex sources matched against the raw model id after profile suffixes are - * stripped. Use this when simple prefixes are not expressive enough. - */ - modelPatterns?: string[]; -}; - -export type PluginManifestModelCatalog = ModelCatalog; - -export type PluginManifestModelPricingModelIdTransform = "version-dots"; - -export type PluginManifestModelPricingSource = { - provider?: string; - passthroughProviderModel?: boolean; - modelIdTransforms?: PluginManifestModelPricingModelIdTransform[]; -}; - -export type PluginManifestModelPricingProvider = { - external?: boolean; - openRouter?: PluginManifestModelPricingSource | false; - liteLLM?: PluginManifestModelPricingSource | false; -}; - -export type PluginManifestModelPricing = { - providers?: Record; -}; - -type PluginManifestModelIdPrefixRule = { - modelPrefix: string; - prefix: string; -}; - -export type PluginManifestModelIdNormalizationProvider = { - aliases?: Record; - stripPrefixes?: string[]; - prefixWhenBare?: string; - prefixWhenBareAfterAliasStartsWith?: PluginManifestModelIdPrefixRule[]; -}; - -export type PluginManifestModelIdNormalization = { - providers?: Record; -}; - -export type PluginManifestProviderEndpoint = { - /** - * Core endpoint class this plugin-owned endpoint should map to. Core must - * already know the class; manifests own host/baseUrl matching metadata. - */ - endpointClass: string; - /** Hostnames that should resolve to this endpoint class. */ - hosts?: string[]; - /** Host suffixes that should resolve to this endpoint class. */ - hostSuffixes?: string[]; - /** Exact normalized base URLs that should resolve to this endpoint class. */ - baseUrls?: string[]; - /** Static Google Vertex region metadata for exact global hosts. */ - googleVertexRegion?: string; - /** Host suffix whose prefix should be exposed as the Google Vertex region. */ - googleVertexRegionHostSuffix?: string; -}; - -export type PluginManifestProviderRequestProvider = { - family?: string; - compatibilityFamily?: "moonshot"; - openAICompletions?: { - supportsStreamingUsage?: boolean; - }; -}; - -export type PluginManifestProviderRequest = { - providers?: Record; -}; - -export type PluginManifestSecretProviderIntegration = { - providerAlias?: string; - displayName?: string; - description?: string; - source: "exec"; - command: "${node}"; - args?: string[]; - timeoutMs?: number; - noOutputTimeoutMs?: number; - maxOutputBytes?: number; - jsonOnly?: boolean; - env?: Record; - passEnv?: string[]; - allowInsecurePath?: boolean; -}; - -export type PluginManifestActivationCapability = "provider" | "channel" | "tool" | "hook"; - -export type PluginManifestActivation = { - /** - * Explicit Gateway startup activation. Set true when the plugin must be - * imported during Gateway startup; set false when narrower activation - * triggers should load it on demand. - */ - onStartup?: boolean; - /** - * Provider ids that should include this plugin in activation/load plans. - * This is planner metadata only; runtime behavior still comes from register(). - */ - onProviders?: string[]; - /** Agent harness runtime ids that should include this plugin in activation/load plans. */ - onAgentHarnesses?: string[]; - /** Command ids that should include this plugin in activation/load plans. */ - onCommands?: string[]; - /** Channel ids that should include this plugin in activation/load plans. */ - onChannels?: string[]; - /** Route kinds that should include this plugin in activation/load plans. */ - onRoutes?: string[]; - /** Root-relative config paths that should include this plugin in startup/load plans. */ - onConfigPaths?: string[]; - /** Broad capability hints for activation/load plans. Prefer narrower ownership metadata. */ - onCapabilities?: PluginManifestActivationCapability[]; -}; - -type PluginManifestDefaultPlatform = NodeJS.Platform; - -type PluginManifestSetupProvider = { - /** Provider id surfaced during setup/onboarding. */ - id: string; - /** Setup/auth methods that this provider supports. */ - authMethods?: string[]; - /** Environment variables that can satisfy setup without runtime loading. */ - envVars?: string[]; - /** - * Cheap local evidence that a provider can authenticate without loading - * runtime code. Evidence checks must not read secrets, shell out, or call - * provider APIs. - */ - authEvidence?: PluginManifestSetupProviderAuthEvidence[]; -}; - -type PluginManifestSetupProviderAuthEvidence = { - /** Generic local file evidence gated by required environment metadata. */ - type: "local-file-with-env"; - /** Optional env var containing an explicit credential file path. */ - fileEnvVar?: string; - /** Optional fallback credential file paths. Supports `${HOME}` and `${APPDATA}`. */ - fallbackPaths?: string[]; - /** At least one of these env vars must be non-empty when provided. */ - requiresAnyEnv?: string[]; - /** Every env var listed here must be non-empty when provided. */ - requiresAllEnv?: string[]; - /** Non-secret marker returned when this evidence is present. */ - credentialMarker: string; - /** Human-readable auth source label. */ - source?: string; -}; - -export type PluginManifestSetup = { - /** Cheap provider setup metadata exposed before runtime loads. */ - providers?: PluginManifestSetupProvider[]; - /** Setup-time backend ids available without full runtime activation. */ - cliBackends?: string[]; - /** Config migration ids owned by this plugin's setup surface. */ - configMigrations?: string[]; - /** - * Whether setup still needs plugin runtime execution after descriptor lookup. - * Defaults to false when omitted. - */ - requiresRuntime?: boolean; -}; - -export type PluginManifestQaRunner = { - /** Subcommand mounted beneath `openclaw qa`, for example `matrix`. */ - commandName: string; - /** Optional user-facing help text for fallback host stubs. */ - description?: string; -}; - -export type PluginManifestDashboardDataBinding = { - /** Plugin-local id. Widget grants receive the plugin-id prefix. */ - id: string; - /** Read-scoped Gateway method registered by this plugin. */ - method: string; - description: string; -}; - -export type PluginManifestDashboardActionVerb = { - /** Plugin-local id. Widget grants receive the plugin-id prefix. */ - id: string; - /** Write-scoped Gateway method registered by this plugin. */ - method: string; - description: string; - /** Optional JSON Schema for the action params object. */ - paramShape?: JsonSchemaObject; -}; - -export type PluginManifestDashboard = { - dataBindings?: PluginManifestDashboardDataBinding[]; - actionVerbs?: PluginManifestDashboardActionVerb[]; -}; - -export type PluginManifestMcpServer = Record; - -type PluginManifestConfigLiteral = string | number | boolean | null; - -type PluginManifestDangerousConfigFlag = { - /** - * Dot-separated config path relative to `plugins.entries..config`. - * Supports `*` wildcards for map/array segments. - */ - path: string; - /** Exact literal that marks this config value as dangerous. */ - equals: PluginManifestConfigLiteral; -}; - -type PluginManifestSecretInputPath = { - /** - * Dot-separated config path relative to `plugins.entries..config`. - * Supports `*` wildcards for map/array segments. - */ - path: string; - /** Expected resolved type for SecretRef materialization. */ - expected?: "string"; - /** Runtime owner kind used to isolate this surface when resolution fails. */ - ownerKind?: "route"; -}; - -type PluginManifestSecretInputContracts = { - /** - * Override bundled-plugin default enablement when deciding whether this - * SecretRef surface is active. Use this when the plugin is bundled but the - * surface should stay inactive until explicitly enabled in config. - */ - bundledDefaultEnabled?: boolean; - paths: PluginManifestSecretInputPath[]; -}; - -export type PluginManifestConfigContracts = { - /** - * Root-relative config paths that indicate this plugin's setup-time - * compatibility migrations might apply. Use this to keep generic runtime - * config reads from loading every plugin setup surface when the config does - * not reference the plugin at all. - */ - compatibilityMigrationPaths?: string[]; - /** - * Root-relative compatibility paths that this plugin can service during - * runtime before plugin code fully activates. Use this for legacy surfaces - * that should cheaply narrow bundled candidate sets without importing every - * compatible plugin runtime. - */ - compatibilityRuntimePaths?: string[]; - dangerousFlags?: PluginManifestDangerousConfigFlag[]; - secretInputs?: PluginManifestSecretInputContracts; -}; - -export type PluginManifestCatalog = { - featured?: boolean; - order?: number; -}; - -export type PluginManifest = { - id: string; - configSchema: JsonSchemaObject; - /** Plugin ids that must also be installed for this plugin to have effect. */ - requiresPlugins?: string[]; - enabledByDefault?: boolean; - enabledByDefaultOnPlatforms?: PluginManifestDefaultPlatform[]; - /** Legacy plugin ids that should normalize to this plugin id. */ - legacyPluginIds?: string[]; - /** Provider ids that should auto-enable this plugin when referenced in auth/config/models. */ - autoEnableWhenConfiguredProviders?: string[]; - kind?: PluginKind | PluginKind[]; - channels?: string[]; - providers?: string[]; - /** - * Optional lightweight module that exports provider plugin metadata for - * auth/catalog discovery. It should not import the full plugin runtime. - */ - providerCatalogEntry?: string; - /** - * Cheap model-family ownership metadata used before plugin runtime loads. - * Use this for shorthand model refs that omit an explicit provider prefix. - */ - modelSupport?: PluginManifestModelSupport; - /** - * Declarative model catalog metadata used by future read-only listing, - * onboarding, and model picker surfaces before provider runtime loads. - */ - modelCatalog?: PluginManifestModelCatalog; - /** Manifest-owned external pricing lookup policy for provider refs. */ - modelPricing?: PluginManifestModelPricing; - /** Manifest-owned model-id normalization used before provider runtime loads. */ - modelIdNormalization?: PluginManifestModelIdNormalization; - /** Cheap provider endpoint metadata used before provider runtime loads. */ - providerEndpoints?: PluginManifestProviderEndpoint[]; - /** Cheap provider request metadata used before provider runtime loads. */ - providerRequest?: PluginManifestProviderRequest; - /** Declarative SecretRef provider presets owned by this plugin. */ - secretProviderIntegrations?: Record; - /** Cheap startup activation lookup for plugin-owned CLI inference backends. */ - cliBackends?: string[]; - /** - * Provider or CLI backend refs whose plugin-owned synthetic auth hook should - * be probed during cold model discovery before the runtime registry exists. - */ - syntheticAuthRefs?: string[]; - /** - * Bundled-plugin-owned placeholder API key values that represent non-secret - * local, OAuth, or ambient credential state. - */ - nonSecretAuthMarkers?: string[]; - /** - * Plugin-owned command aliases that should resolve to this plugin during - * config diagnostics before runtime loads. - */ - commandAliases?: PluginManifestCommandAlias[]; - /** Usage/billing credentials excluded from inference auth but included in secret scrubbing. */ - providerUsageAuthEnvVars?: Record; - /** Provider ids that should reuse another provider id for auth lookup. */ - providerAuthAliases?: Record; - /** - * Cheap onboarding/auth-choice metadata used by config validation, CLI help, - * and non-runtime auth-choice routing before provider runtime loads. - */ - providerAuthChoices?: PluginManifestProviderAuthChoice[]; - /** Cheap activation planner metadata exposed before plugin runtime loads. */ - activation?: PluginManifestActivation; - /** Cheap setup/onboarding metadata exposed before plugin runtime loads. */ - setup?: PluginManifestSetup; - /** Cheap QA runner metadata exposed before plugin runtime loads. */ - qaRunners?: PluginManifestQaRunner[]; - /** Widget data and action capabilities validated against runtime registrations. */ - dashboard?: PluginManifestDashboard; - /** Static MCP servers contributed while this plugin is enabled. */ - mcpServers?: Record; - skills?: string[]; - name?: string; - description?: string; - /** Optional presentation hints for plugin catalog surfaces. */ - catalog?: PluginManifestCatalog; - /** Optional HTTPS URL for marketplace/catalog card artwork. */ - icon?: string; - version?: string; - uiHints?: Record; - /** - * Static capability ownership snapshot used for manifest-driven discovery, - * compat wiring, and contract coverage without importing plugin runtime. - */ - contracts?: PluginManifestContracts; - /** Cheap media-understanding provider defaults without importing plugin runtime. */ - mediaUnderstandingProviderMetadata?: Record< - string, - PluginManifestMediaUnderstandingProviderMetadata - >; - /** Cheap image-generation provider auth metadata without importing plugin runtime. */ - imageGenerationProviderMetadata?: Record; - /** Cheap video-generation provider auth metadata without importing plugin runtime. */ - videoGenerationProviderMetadata?: Record; - /** Cheap music-generation provider auth metadata without importing plugin runtime. */ - musicGenerationProviderMetadata?: Record; - /** Cheap plugin-tool availability metadata without importing plugin runtime. */ - toolMetadata?: Record; - /** Manifest-owned config behavior consumed by generic core helpers. */ - configContracts?: PluginManifestConfigContracts; - channelConfigs?: Record; -}; - -export type PluginManifestContracts = { - embeddedExtensionFactories?: string[]; - agentToolResultMiddleware?: string[]; - trustedToolPolicies?: string[]; - /** - * Provider ids whose external auth profile hook can contribute runtime-only - * credentials. Declaring this lets auth-store overlays load only the owning - * plugin instead of every provider plugin. - */ - externalAuthProviders?: string[]; - embeddingProviders?: string[]; - memoryEmbeddingProviders?: string[]; - speechProviders?: string[]; - realtimeTranscriptionProviders?: string[]; - realtimeVoiceProviders?: string[]; - mediaUnderstandingProviders?: string[]; - transcriptSourceProviders?: string[]; - documentExtractors?: string[]; - imageGenerationProviders?: string[]; - videoGenerationProviders?: string[]; - musicGenerationProviders?: string[]; - webContentExtractors?: string[]; - webFetchProviders?: string[]; - webSearchProviders?: string[]; - workerProviders?: string[]; - /** Provider ids whose plugin owns usage auth and snapshot hooks. */ - usageProviders?: string[]; - migrationProviders?: string[]; - gatewayMethodDispatch?: string[]; - tools?: string[]; -}; - -type PluginManifestMediaUnderstandingCapability = "image" | "audio" | "video"; - -export type PluginManifestMediaUnderstandingProviderMetadata = { - capabilities?: PluginManifestMediaUnderstandingCapability[]; - defaultModels?: Partial>; - autoPriority?: Partial>; - nativeDocumentInputs?: Array<"pdf">; - documentModels?: Partial< - Record< - "pdf", - { - textExtraction?: string; - image?: string | false; - } - > - >; -}; - -type PluginManifestProviderBaseUrlGuard = { - provider: string; - defaultBaseUrl?: string; - allowedBaseUrls: string[]; -}; - -export type PluginManifestCapabilityProviderAuthSignal = { - provider: string; - providerBaseUrl?: PluginManifestProviderBaseUrlGuard; -}; - -type PluginManifestCapabilityProviderModeConfigSignal = { - path?: string; - default?: string; - allowed?: string[]; - disallowed?: string[]; -}; - -export type PluginManifestCapabilityProviderConfigSignal = { - rootPath: string; - overlayPath?: string; - overlayMapPath?: string; - required?: string[]; - requiredAny?: string[]; - mode?: PluginManifestCapabilityProviderModeConfigSignal; -}; - -export type PluginManifestCapabilityProviderMetadata = { - aliases?: string[]; - authProviders?: string[]; - authSignals?: PluginManifestCapabilityProviderAuthSignal[]; - configSignals?: PluginManifestCapabilityProviderConfigSignal[]; - referenceAudioInputs?: boolean; -}; - -export type PluginManifestToolMetadata = PluginManifestCapabilityProviderMetadata & { - optional?: boolean; - /** Tool execution is safe to repeat after an incomplete model turn. */ - replaySafe?: boolean; -}; - -type PluginManifestProviderAuthChoice = { - /** Provider id owned by this manifest entry. */ - provider: string; - /** Provider auth method id that this choice should dispatch to. */ - method: string; - /** Stable auth-choice id used by onboarding and other CLI auth flows. */ - choiceId: string; - /** Optional user-facing choice label/hint for grouped onboarding UI. */ - choiceLabel?: string; - choiceHint?: string; - /** Optional HTTPS artwork URL for native and web onboarding surfaces. */ - icon?: string; - /** Optional HTTPS product or installation URL for onboarding surfaces. */ - website?: string; - /** Lower values sort earlier in interactive assistant pickers. */ - assistantPriority?: number; - /** Keep the choice out of interactive assistant pickers while preserving manual CLI support. */ - assistantVisibility?: "visible" | "manual-only"; - /** Legacy choice ids that should point users at this replacement choice. */ - deprecatedChoiceIds?: string[]; - /** Optional grouping metadata for auth-choice pickers. */ - groupId?: string; - groupLabel?: string; - groupHint?: string; - /** - * Surface this group in the featured tier of the interactive onboarding - * picker. Featured groups appear before the "More…" entry. - */ - onboardingFeatured?: boolean; - /** Optional CLI flag metadata for one-flag auth flows such as API keys. */ - optionKey?: string; - cliFlag?: string; - cliOption?: string; - cliDescription?: string; - /** One pasted secret plus provider defaults is sufficient for app-guided setup. */ - appGuidedSecret?: boolean; - /** Provider-owned interactive login that native setup clients can render generically. */ - appGuidedAuth?: "oauth" | "device-code"; - /** - * Interactive onboarding surfaces where this auth choice should appear. - * Defaults to `["text-inference"]` when omitted. - */ - onboardingScopes?: PluginManifestOnboardingScope[]; - /** Provider runtime can discover and prepare an already-installed local model. */ - appGuidedDiscovery?: boolean; -}; - -type PluginManifestOnboardingScope = "text-inference" | "image-generation" | "music-generation"; - -type PluginManifestLoadResult = - | { ok: true; manifest: PluginManifest; manifestPath: string } - | { ok: false; error: string; manifestPath: string }; - -function normalizeStringListRecord(value: unknown): Record | undefined { - if (!isRecord(value)) { - return undefined; - } - const normalized: Record = Object.create(null); - for (const [key, rawValues] of Object.entries(value)) { - const providerId = normalizeOptionalString(key) ?? ""; - if (!providerId || isBlockedObjectKey(providerId)) { - continue; - } - const values = normalizeTrimmedStringList(rawValues); - if (values.length === 0) { - continue; - } - normalized[providerId] = values; - } - return Object.keys(normalized).length > 0 ? normalized : undefined; -} - -function normalizeStringRecord(value: unknown): Record | undefined { - if (!isRecord(value)) { - return undefined; - } - const normalized: Record = Object.create(null); - for (const [rawKey, rawValue] of Object.entries(value)) { - const key = normalizeOptionalString(rawKey) ?? ""; - const valueLocal = normalizeOptionalString(rawValue) ?? ""; - if (!key || isBlockedObjectKey(key) || !valueLocal) { - continue; - } - normalized[key] = valueLocal; - } - return Object.keys(normalized).length > 0 ? normalized : undefined; -} - -function normalizeManifestMcpServers( - value: unknown, -): Record | undefined { - if (!isRecord(value)) { - return undefined; - } - const normalized: Record = Object.create(null); - for (const [rawName, rawServer] of Object.entries(value)) { - const name = normalizeOptionalString(rawName) ?? ""; - if (!name || isBlockedObjectKey(name) || !isRecord(rawServer)) { - continue; - } - normalized[name] = { ...rawServer }; - } - return Object.keys(normalized).length > 0 ? normalized : undefined; -} - -const MEDIA_UNDERSTANDING_CAPABILITIES = new Set(["image", "audio", "video"]); - -function normalizeMediaUnderstandingCapabilityRecord( - value: unknown, -): Partial> | undefined { - if (!isRecord(value)) { - return undefined; - } - const normalized: Partial> = {}; - for (const [rawKey, rawValue] of Object.entries(value)) { - if (!MEDIA_UNDERSTANDING_CAPABILITIES.has(rawKey)) { - continue; - } - const model = normalizeOptionalString(rawValue); - if (model) { - normalized[rawKey as PluginManifestMediaUnderstandingCapability] = model; - } - } - return Object.keys(normalized).length > 0 ? normalized : undefined; -} - -function normalizeMediaUnderstandingPriorityRecord( - value: unknown, -): Partial> | undefined { - if (!isRecord(value)) { - return undefined; - } - const normalized: Partial> = {}; - for (const [rawKey, rawValue] of Object.entries(value)) { - if ( - !MEDIA_UNDERSTANDING_CAPABILITIES.has(rawKey) || - typeof rawValue !== "number" || - !Number.isFinite(rawValue) - ) { - continue; - } - normalized[rawKey as PluginManifestMediaUnderstandingCapability] = rawValue; - } - return Object.keys(normalized).length > 0 ? normalized : undefined; -} - -function normalizeMediaUnderstandingCapabilities( - value: unknown, -): PluginManifestMediaUnderstandingCapability[] | undefined { - const values = normalizeTrimmedStringList(value).filter((entry) => - MEDIA_UNDERSTANDING_CAPABILITIES.has(entry), - ) as PluginManifestMediaUnderstandingCapability[]; - return values.length > 0 ? values : undefined; -} - -function normalizeMediaUnderstandingNativeDocumentInputs(value: unknown): Array<"pdf"> | undefined { - const values = normalizeTrimmedStringList(value).filter((entry) => entry === "pdf"); - return values.length > 0 ? values : undefined; -} - -function normalizeMediaUnderstandingDocumentModels( - value: unknown, -): PluginManifestMediaUnderstandingProviderMetadata["documentModels"] | undefined { - if (!isRecord(value)) { - return undefined; - } - const pdfRaw = value.pdf; - if (!isRecord(pdfRaw)) { - return undefined; - } - const textExtraction = normalizeOptionalString(pdfRaw.textExtraction); - const image: string | false | undefined = - pdfRaw.image === false ? false : normalizeOptionalString(pdfRaw.image); - const pdf = { - ...(textExtraction ? { textExtraction } : {}), - ...(image !== undefined ? { image } : {}), - }; - return Object.keys(pdf).length > 0 ? { pdf } : undefined; -} - -function normalizeMediaUnderstandingProviderMetadata( - value: unknown, -): Record | undefined { - if (!isRecord(value)) { - return undefined; - } - const normalized: Record = - Object.create(null); - for (const [rawProviderId, rawMetadata] of Object.entries(value)) { - const providerId = normalizeOptionalString(rawProviderId) ?? ""; - if (!providerId || isBlockedObjectKey(providerId) || !isRecord(rawMetadata)) { - continue; - } - const capabilities = normalizeMediaUnderstandingCapabilities(rawMetadata.capabilities); - const defaultModels = normalizeMediaUnderstandingCapabilityRecord(rawMetadata.defaultModels); - const autoPriority = normalizeMediaUnderstandingPriorityRecord(rawMetadata.autoPriority); - const nativeDocumentInputs = normalizeMediaUnderstandingNativeDocumentInputs( - rawMetadata.nativeDocumentInputs, - ); - const documentModels = normalizeMediaUnderstandingDocumentModels(rawMetadata.documentModels); - const metadata = { - ...(capabilities ? { capabilities } : {}), - ...(defaultModels ? { defaultModels } : {}), - ...(autoPriority ? { autoPriority } : {}), - ...(nativeDocumentInputs ? { nativeDocumentInputs } : {}), - ...(documentModels ? { documentModels } : {}), - } satisfies PluginManifestMediaUnderstandingProviderMetadata; - if (Object.keys(metadata).length > 0) { - normalized[providerId] = metadata; - } - } - return Object.keys(normalized).length > 0 ? normalized : undefined; -} - -function normalizeProviderBaseUrlGuard( - value: unknown, -): PluginManifestProviderBaseUrlGuard | undefined { - if (!isRecord(value)) { - return undefined; - } - const provider = normalizeOptionalString(value.provider); - const allowedBaseUrls = normalizeTrimmedStringList(value.allowedBaseUrls); - if (!provider || allowedBaseUrls.length === 0) { - return undefined; - } - const defaultBaseUrl = normalizeOptionalString(value.defaultBaseUrl); - return { - provider, - ...(defaultBaseUrl ? { defaultBaseUrl } : {}), - allowedBaseUrls, - }; -} - -function normalizeCapabilityProviderAuthSignals( - value: unknown, -): PluginManifestCapabilityProviderAuthSignal[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - const signals: PluginManifestCapabilityProviderAuthSignal[] = []; - for (const rawSignal of value) { - if (!isRecord(rawSignal)) { - continue; - } - const provider = normalizeOptionalString(rawSignal.provider); - if (!provider) { - continue; - } - const providerBaseUrl = normalizeProviderBaseUrlGuard(rawSignal.providerBaseUrl); - signals.push({ - provider, - ...(providerBaseUrl ? { providerBaseUrl } : {}), - }); - } - return signals.length > 0 ? signals : undefined; -} - -function normalizeCapabilityProviderModeConfigSignal( - value: unknown, -): PluginManifestCapabilityProviderModeConfigSignal | undefined { - if (!isRecord(value)) { - return undefined; - } - const pathResult = normalizeOptionalString(value.path); - const defaultValue = normalizeOptionalString(value.default); - const allowed = normalizeTrimmedStringList(value.allowed); - const disallowed = normalizeTrimmedStringList(value.disallowed); - const signal = { - ...(pathResult ? { path: pathResult } : {}), - ...(defaultValue ? { default: defaultValue } : {}), - ...(allowed.length > 0 ? { allowed } : {}), - ...(disallowed.length > 0 ? { disallowed } : {}), - } satisfies PluginManifestCapabilityProviderModeConfigSignal; - return Object.keys(signal).length > 0 ? signal : undefined; -} - -function normalizeCapabilityProviderConfigSignals( - value: unknown, -): PluginManifestCapabilityProviderConfigSignal[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - const signals: PluginManifestCapabilityProviderConfigSignal[] = []; - for (const rawSignal of value) { - if (!isRecord(rawSignal)) { - continue; - } - const rootPath = normalizeOptionalString(rawSignal.rootPath); - if (!rootPath) { - continue; - } - const overlayPath = normalizeOptionalString(rawSignal.overlayPath); - const overlayMapPath = normalizeOptionalString(rawSignal.overlayMapPath); - const required = normalizeTrimmedStringList(rawSignal.required); - const requiredAny = normalizeTrimmedStringList(rawSignal.requiredAny); - const mode = normalizeCapabilityProviderModeConfigSignal(rawSignal.mode); - const signal = { - rootPath, - ...(overlayPath ? { overlayPath } : {}), - ...(overlayMapPath ? { overlayMapPath } : {}), - ...(required.length > 0 ? { required } : {}), - ...(requiredAny.length > 0 ? { requiredAny } : {}), - ...(mode ? { mode } : {}), - } satisfies PluginManifestCapabilityProviderConfigSignal; - if (required.length > 0 || requiredAny.length > 0 || mode) { - signals.push(signal); - } - } - return signals.length > 0 ? signals : undefined; -} - -function normalizeCapabilityProviderMetadataEntry( - rawMetadata: Record, -): PluginManifestCapabilityProviderMetadata | undefined { - const aliases = normalizeTrimmedStringList(rawMetadata.aliases); - const authProviders = normalizeTrimmedStringList(rawMetadata.authProviders); - const authSignals = normalizeCapabilityProviderAuthSignals(rawMetadata.authSignals); - const configSignals = normalizeCapabilityProviderConfigSignals(rawMetadata.configSignals); - const referenceAudioInputs = rawMetadata.referenceAudioInputs === true ? true : undefined; - const metadata = { - ...(aliases.length > 0 ? { aliases } : {}), - ...(authProviders.length > 0 ? { authProviders } : {}), - ...(authSignals ? { authSignals } : {}), - ...(configSignals ? { configSignals } : {}), - ...(referenceAudioInputs ? { referenceAudioInputs } : {}), - } satisfies PluginManifestCapabilityProviderMetadata; - return Object.keys(metadata).length > 0 ? metadata : undefined; -} - -function normalizeCapabilityProviderMetadata( - value: unknown, -): Record | undefined { - if (!isRecord(value)) { - return undefined; - } - const normalized: Record = Object.create(null); - for (const [rawProviderId, rawMetadata] of Object.entries(value)) { - const providerId = normalizeOptionalString(rawProviderId) ?? ""; - if (!providerId || isBlockedObjectKey(providerId) || !isRecord(rawMetadata)) { - continue; - } - const metadata = normalizeCapabilityProviderMetadataEntry(rawMetadata); - if (metadata) { - normalized[providerId] = metadata; - } - } - return Object.keys(normalized).length > 0 ? normalized : undefined; -} - -function normalizePluginToolMetadata( - value: unknown, -): Record | undefined { - if (!isRecord(value)) { - return undefined; - } - const normalized: Record = Object.create(null); - for (const [rawToolName, rawMetadata] of Object.entries(value)) { - const toolName = normalizeOptionalString(rawToolName) ?? ""; - if (!toolName || isBlockedObjectKey(toolName) || !isRecord(rawMetadata)) { - continue; - } - const providerMetadata = normalizeCapabilityProviderMetadataEntry(rawMetadata); - const metadata = { - ...providerMetadata, - ...(rawMetadata.optional === true ? { optional: true } : {}), - ...(rawMetadata.replaySafe === true ? { replaySafe: true } : {}), - } satisfies PluginManifestToolMetadata; - if (Object.keys(metadata).length > 0) { - normalized[toolName] = metadata; - } - } - return Object.keys(normalized).length > 0 ? normalized : undefined; -} - -function normalizeManifestCatalog(value: unknown): PluginManifestCatalog | undefined { - if (!isRecord(value)) { - return undefined; - } - const featured = typeof value.featured === "boolean" ? value.featured : undefined; - const order = - typeof value.order === "number" && Number.isFinite(value.order) ? value.order : undefined; - if (featured === undefined && order === undefined) { - return undefined; - } - return { - ...(featured !== undefined ? { featured } : {}), - ...(order !== undefined ? { order } : {}), - }; -} - -function normalizeManifestContracts(value: unknown): PluginManifestContracts | undefined { - if (!isRecord(value)) { - return undefined; - } - - const embeddedExtensionFactories = normalizeTrimmedStringList(value.embeddedExtensionFactories); - const agentToolResultMiddleware = normalizeTrimmedStringList(value.agentToolResultMiddleware); - const trustedToolPolicies = normalizeTrimmedStringList(value.trustedToolPolicies); - const externalAuthProviders = normalizeTrimmedStringList(value.externalAuthProviders); - const embeddingProviders = normalizeTrimmedStringList(value.embeddingProviders); - const memoryEmbeddingProviders = normalizeTrimmedStringList(value.memoryEmbeddingProviders); - const speechProviders = normalizeTrimmedStringList(value.speechProviders); - const realtimeTranscriptionProviders = normalizeTrimmedStringList( - value.realtimeTranscriptionProviders, - ); - const realtimeVoiceProviders = normalizeTrimmedStringList(value.realtimeVoiceProviders); - const mediaUnderstandingProviders = normalizeTrimmedStringList(value.mediaUnderstandingProviders); - const transcriptSourceProviders = normalizeTrimmedStringList(value.transcriptSourceProviders); - const documentExtractors = normalizeTrimmedStringList(value.documentExtractors); - const imageGenerationProviders = normalizeTrimmedStringList(value.imageGenerationProviders); - const videoGenerationProviders = normalizeTrimmedStringList(value.videoGenerationProviders); - const musicGenerationProviders = normalizeTrimmedStringList(value.musicGenerationProviders); - const webContentExtractors = normalizeTrimmedStringList(value.webContentExtractors); - const webFetchProviders = normalizeTrimmedStringList(value.webFetchProviders); - const webSearchProviders = normalizeTrimmedStringList(value.webSearchProviders); - const workerProviders = normalizeTrimmedStringList(value.workerProviders); - const usageProviders = normalizeTrimmedStringList(value.usageProviders); - const migrationProviders = normalizeTrimmedStringList(value.migrationProviders); - const gatewayMethodDispatch = normalizeTrimmedStringList(value.gatewayMethodDispatch); - const tools = normalizeTrimmedStringList(value.tools); - const contracts = { - ...(embeddedExtensionFactories.length > 0 ? { embeddedExtensionFactories } : {}), - ...(agentToolResultMiddleware.length > 0 ? { agentToolResultMiddleware } : {}), - ...(trustedToolPolicies.length > 0 ? { trustedToolPolicies } : {}), - ...(externalAuthProviders.length > 0 ? { externalAuthProviders } : {}), - ...(embeddingProviders.length > 0 ? { embeddingProviders } : {}), - ...(memoryEmbeddingProviders.length > 0 ? { memoryEmbeddingProviders } : {}), - ...(speechProviders.length > 0 ? { speechProviders } : {}), - ...(realtimeTranscriptionProviders.length > 0 ? { realtimeTranscriptionProviders } : {}), - ...(realtimeVoiceProviders.length > 0 ? { realtimeVoiceProviders } : {}), - ...(mediaUnderstandingProviders.length > 0 ? { mediaUnderstandingProviders } : {}), - ...(transcriptSourceProviders.length > 0 ? { transcriptSourceProviders } : {}), - ...(documentExtractors.length > 0 ? { documentExtractors } : {}), - ...(imageGenerationProviders.length > 0 ? { imageGenerationProviders } : {}), - ...(videoGenerationProviders.length > 0 ? { videoGenerationProviders } : {}), - ...(musicGenerationProviders.length > 0 ? { musicGenerationProviders } : {}), - ...(webContentExtractors.length > 0 ? { webContentExtractors } : {}), - ...(webFetchProviders.length > 0 ? { webFetchProviders } : {}), - ...(webSearchProviders.length > 0 ? { webSearchProviders } : {}), - ...(workerProviders.length > 0 ? { workerProviders } : {}), - ...(usageProviders.length > 0 ? { usageProviders } : {}), - ...(migrationProviders.length > 0 ? { migrationProviders } : {}), - ...(gatewayMethodDispatch.length > 0 ? { gatewayMethodDispatch } : {}), - ...(tools.length > 0 ? { tools } : {}), - } satisfies PluginManifestContracts; - - return Object.keys(contracts).length > 0 ? contracts : undefined; -} - -function isManifestConfigLiteral(value: unknown): value is PluginManifestConfigLiteral { - return ( - value === null || - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" - ); -} - -function normalizeManifestDangerousConfigFlags( - value: unknown, -): PluginManifestDangerousConfigFlag[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - const normalized: PluginManifestDangerousConfigFlag[] = []; - for (const entry of value) { - if (!isRecord(entry)) { - continue; - } - const pathValue = normalizeOptionalString(entry.path) ?? ""; - if (!pathValue || !isManifestConfigLiteral(entry.equals)) { - continue; - } - normalized.push({ path: pathValue, equals: entry.equals }); - } - return normalized.length > 0 ? normalized : undefined; -} - -function normalizeManifestSecretInputPaths( - value: unknown, -): PluginManifestSecretInputPath[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - const normalized: PluginManifestSecretInputPath[] = []; - for (const entry of value) { - if (!isRecord(entry)) { - continue; - } - const pathLocal = normalizeOptionalString(entry.path) ?? ""; - if (!pathLocal) { - continue; - } - const expected = entry.expected === "string" ? entry.expected : undefined; - const ownerKind = entry.ownerKind === "route" ? entry.ownerKind : undefined; - normalized.push({ - path: pathLocal, - ...(expected ? { expected } : {}), - ...(ownerKind ? { ownerKind } : {}), - }); - } - return normalized.length > 0 ? normalized : undefined; -} - -function normalizeManifestConfigContracts( - value: unknown, -): PluginManifestConfigContracts | undefined { - if (!isRecord(value)) { - return undefined; - } - const compatibilityMigrationPaths = normalizeTrimmedStringList(value.compatibilityMigrationPaths); - const compatibilityRuntimePaths = normalizeTrimmedStringList(value.compatibilityRuntimePaths); - const rawSecretInputs = isRecord(value.secretInputs) ? value.secretInputs : undefined; - const dangerousFlags = normalizeManifestDangerousConfigFlags(value.dangerousFlags); - const secretInputPaths = rawSecretInputs - ? normalizeManifestSecretInputPaths(rawSecretInputs.paths) - : undefined; - const secretInputs = - secretInputPaths && secretInputPaths.length > 0 - ? ({ - ...(rawSecretInputs?.bundledDefaultEnabled === true - ? { bundledDefaultEnabled: true } - : rawSecretInputs?.bundledDefaultEnabled === false - ? { bundledDefaultEnabled: false } - : {}), - paths: secretInputPaths, - } satisfies PluginManifestSecretInputContracts) - : undefined; - const configContracts = { - ...(compatibilityMigrationPaths.length > 0 ? { compatibilityMigrationPaths } : {}), - ...(compatibilityRuntimePaths.length > 0 ? { compatibilityRuntimePaths } : {}), - ...(dangerousFlags ? { dangerousFlags } : {}), - ...(secretInputs ? { secretInputs } : {}), - } satisfies PluginManifestConfigContracts; - return Object.keys(configContracts).length > 0 ? configContracts : undefined; -} - -function normalizeManifestModelSupport(value: unknown): PluginManifestModelSupport | undefined { - if (!isRecord(value)) { - return undefined; - } - - const modelPrefixes = normalizeTrimmedStringList(value.modelPrefixes); - const modelPatterns = normalizeTrimmedStringList(value.modelPatterns); - const modelSupport = { - ...(modelPrefixes.length > 0 ? { modelPrefixes } : {}), - ...(modelPatterns.length > 0 ? { modelPatterns } : {}), - } satisfies PluginManifestModelSupport; - - return Object.keys(modelSupport).length > 0 ? modelSupport : undefined; -} - -function normalizeManifestModelPricingSource( - value: unknown, -): PluginManifestModelPricingSource | false | undefined { - if (value === false) { - return false; - } - if (!isRecord(value)) { - return undefined; - } - const provider = normalizeModelCatalogProviderId(normalizeOptionalString(value.provider) ?? ""); - const modelIdTransforms = normalizeTrimmedStringList(value.modelIdTransforms).filter( - (entry): entry is PluginManifestModelPricingModelIdTransform => entry === "version-dots", - ); - const source = { - ...(provider ? { provider } : {}), - ...(value.passthroughProviderModel === true ? { passthroughProviderModel: true } : {}), - ...(modelIdTransforms.length > 0 ? { modelIdTransforms } : {}), - } satisfies PluginManifestModelPricingSource; - return Object.keys(source).length > 0 ? source : undefined; -} - -function normalizeManifestModelPricingProvider( - value: unknown, -): PluginManifestModelPricingProvider | undefined { - if (!isRecord(value)) { - return undefined; - } - const openRouter = normalizeManifestModelPricingSource(value.openRouter); - const liteLLM = normalizeManifestModelPricingSource(value.liteLLM); - const policy = { - ...(typeof value.external === "boolean" ? { external: value.external } : {}), - ...(openRouter !== undefined ? { openRouter } : {}), - ...(liteLLM !== undefined ? { liteLLM } : {}), - } satisfies PluginManifestModelPricingProvider; - return Object.keys(policy).length > 0 ? policy : undefined; -} - -function normalizeManifestModelPricing( - value: unknown, - params: { ownedProviders: ReadonlySet }, -): PluginManifestModelPricing | undefined { - if (!isRecord(value) || !isRecord(value.providers)) { - return undefined; - } - const ownedProviders = new Set( - [...params.ownedProviders] - .map((provider) => normalizeModelCatalogProviderId(provider)) - .filter(Boolean), - ); - const providers: Record = {}; - for (const [rawProviderId, rawPolicy] of Object.entries(value.providers)) { - const providerId = normalizeModelCatalogProviderId(rawProviderId); - if (!providerId || !ownedProviders.has(providerId)) { - continue; - } - const policy = normalizeManifestModelPricingProvider(rawPolicy); - if (policy) { - providers[providerId] = policy; - } - } - return Object.keys(providers).length > 0 ? { providers } : undefined; -} - -function normalizeManifestModelIdPrefixRules( - value: unknown, -): PluginManifestModelIdPrefixRule[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - const rules: PluginManifestModelIdPrefixRule[] = []; - for (const rawRule of value) { - if (!isRecord(rawRule)) { - continue; - } - const modelPrefix = normalizeOptionalString(rawRule.modelPrefix); - const prefix = normalizeOptionalString(rawRule.prefix); - if (!modelPrefix || !prefix) { - continue; - } - rules.push({ modelPrefix, prefix }); - } - return rules.length > 0 ? rules : undefined; -} - -function normalizeManifestModelIdNormalizationProvider( - value: unknown, -): PluginManifestModelIdNormalizationProvider | undefined { - if (!isRecord(value)) { - return undefined; - } - - const aliases: Record = {}; - if (isRecord(value.aliases)) { - for (const [rawAlias, rawCanonical] of Object.entries(value.aliases)) { - const alias = normalizeModelCatalogProviderId(rawAlias); - const canonical = normalizeOptionalString(rawCanonical); - if (alias && canonical) { - aliases[alias] = canonical; - } - } - } - const stripPrefixes = normalizeTrimmedStringList(value.stripPrefixes); - const prefixWhenBare = normalizeOptionalString(value.prefixWhenBare); - const prefixWhenBareAfterAliasStartsWith = normalizeManifestModelIdPrefixRules( - value.prefixWhenBareAfterAliasStartsWith, - ); - const normalization = { - ...(Object.keys(aliases).length > 0 ? { aliases } : {}), - ...(stripPrefixes.length > 0 ? { stripPrefixes } : {}), - ...(prefixWhenBare ? { prefixWhenBare } : {}), - ...(prefixWhenBareAfterAliasStartsWith ? { prefixWhenBareAfterAliasStartsWith } : {}), - } satisfies PluginManifestModelIdNormalizationProvider; - - return Object.keys(normalization).length > 0 ? normalization : undefined; -} - -function normalizeManifestModelIdNormalization( - value: unknown, - params: { ownedProviders: ReadonlySet }, -): PluginManifestModelIdNormalization | undefined { - if (!isRecord(value) || !isRecord(value.providers)) { - return undefined; - } - const ownedProviders = new Set( - [...params.ownedProviders] - .map((provider) => normalizeModelCatalogProviderId(provider)) - .filter(Boolean), - ); - const providers: Record = {}; - for (const [rawProviderId, rawPolicy] of Object.entries(value.providers)) { - const providerId = normalizeModelCatalogProviderId(rawProviderId); - if (!providerId || !ownedProviders.has(providerId)) { - continue; - } - const policy = normalizeManifestModelIdNormalizationProvider(rawPolicy); - if (policy) { - providers[providerId] = policy; - } - } - return Object.keys(providers).length > 0 ? { providers } : undefined; -} - -function normalizeManifestProviderEndpoints( - value: unknown, -): PluginManifestProviderEndpoint[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - - const endpoints: PluginManifestProviderEndpoint[] = []; - for (const rawEndpoint of value) { - if (!isRecord(rawEndpoint)) { - continue; - } - const endpointClass = normalizeOptionalString(rawEndpoint.endpointClass); - if (!endpointClass) { - continue; - } - const hosts = normalizeTrimmedStringList(rawEndpoint.hosts).map((host) => host.toLowerCase()); - const hostSuffixes = normalizeTrimmedStringList(rawEndpoint.hostSuffixes).map((host) => - host.toLowerCase(), - ); - const baseUrls = normalizeTrimmedStringList(rawEndpoint.baseUrls); - const googleVertexRegion = normalizeOptionalString(rawEndpoint.googleVertexRegion); - const googleVertexRegionHostSuffix = normalizeOptionalString( - rawEndpoint.googleVertexRegionHostSuffix, - )?.toLowerCase(); - if (hosts.length === 0 && hostSuffixes.length === 0 && baseUrls.length === 0) { - continue; - } - endpoints.push({ - endpointClass, - ...(hosts.length > 0 ? { hosts } : {}), - ...(hostSuffixes.length > 0 ? { hostSuffixes } : {}), - ...(baseUrls.length > 0 ? { baseUrls } : {}), - ...(googleVertexRegion ? { googleVertexRegion } : {}), - ...(googleVertexRegionHostSuffix ? { googleVertexRegionHostSuffix } : {}), - }); - } - - return endpoints.length > 0 ? endpoints : undefined; -} - -function normalizeManifestProviderRequestProvider( - value: unknown, -): PluginManifestProviderRequestProvider | undefined { - if (!isRecord(value)) { - return undefined; - } - const family = normalizeOptionalString(value.family); - const compatibilityFamily = - normalizeOptionalString(value.compatibilityFamily) === "moonshot" ? "moonshot" : undefined; - const supportsStreamingUsage = isRecord(value.openAICompletions) - ? value.openAICompletions.supportsStreamingUsage - : undefined; - const openAICompletions = - typeof supportsStreamingUsage === "boolean" ? { supportsStreamingUsage } : undefined; - const providerRequest = { - ...(family ? { family } : {}), - ...(compatibilityFamily ? { compatibilityFamily } : {}), - ...(openAICompletions && Object.keys(openAICompletions).length > 0 - ? { openAICompletions } - : {}), - } satisfies PluginManifestProviderRequestProvider; - return Object.keys(providerRequest).length > 0 ? providerRequest : undefined; -} - -function normalizeManifestProviderRequest( - value: unknown, - params: { ownedProviders: ReadonlySet }, -): PluginManifestProviderRequest | undefined { - if (!isRecord(value) || !isRecord(value.providers)) { - return undefined; - } - const ownedProviders = new Set( - [...params.ownedProviders] - .map((provider) => normalizeModelCatalogProviderId(provider)) - .filter(Boolean), - ); - const providers: Record = {}; - for (const [rawProviderId, rawPolicy] of Object.entries(value.providers)) { - const providerId = normalizeModelCatalogProviderId(rawProviderId); - if (!providerId || !ownedProviders.has(providerId)) { - continue; - } - const policy = normalizeManifestProviderRequestProvider(rawPolicy); - if (policy) { - providers[providerId] = policy; - } - } - return Object.keys(providers).length > 0 ? { providers } : undefined; -} - -function normalizeManifestStringArray( - value: unknown, - options?: { maxItems?: number; maxLength?: number; pattern?: RegExp }, -): string[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - const normalized: string[] = []; - for (const entry of value) { - if (typeof entry !== "string") { - continue; - } - if (options?.maxLength !== undefined && entry.length > options.maxLength) { - continue; - } - if (options?.pattern && !options.pattern.test(entry)) { - continue; - } - normalized.push(entry); - if (options?.maxItems !== undefined && normalized.length >= options.maxItems) { - break; - } - } - return normalized.length > 0 ? normalized : undefined; -} - -function normalizeManifestTrimmedStringArray( - value: unknown, - options?: { maxItems?: number; pattern?: RegExp }, -): string[] | undefined { - const normalized = normalizeTrimmedStringList(value).filter( - (entry) => !options?.pattern || options.pattern.test(entry), - ); - const limited = - options?.maxItems !== undefined ? normalized.slice(0, options.maxItems) : normalized; - return limited.length > 0 ? limited : undefined; -} - -function normalizeManifestPositiveInteger(value: unknown, max: number): number | undefined { - return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= max - ? value - : undefined; -} - -function normalizeManifestSecretProviderIntegrations( - value: unknown, -): Record | undefined { - if (!isRecord(value)) { - return undefined; - } - const normalized: Record = Object.create(null); - for (const [rawId, rawIntegration] of Object.entries(value)) { - const id = normalizeOptionalString(rawId) ?? ""; - if (!id || isBlockedObjectKey(id) || !isRecord(rawIntegration)) { - continue; - } - const command = normalizeOptionalString(rawIntegration.command); - if (rawIntegration.source !== "exec" || command !== SECRET_PROVIDER_NODE_COMMAND_PLACEHOLDER) { - continue; - } - const providerAlias = normalizeOptionalString(rawIntegration.providerAlias); - const displayName = normalizeOptionalString(rawIntegration.displayName); - const description = normalizeOptionalString(rawIntegration.description); - const args = normalizeManifestStringArray(rawIntegration.args, { - maxItems: MAX_SECRET_PROVIDER_EXEC_ARGS, - maxLength: MAX_SECRET_PROVIDER_EXEC_ARG_BYTES, - }); - const timeoutMs = normalizeManifestPositiveInteger( - rawIntegration.timeoutMs, - MAX_SECRET_PROVIDER_EXEC_TIMEOUT_MS, - ); - const noOutputTimeoutMs = normalizeManifestPositiveInteger( - rawIntegration.noOutputTimeoutMs, - MAX_SECRET_PROVIDER_EXEC_TIMEOUT_MS, - ); - const maxOutputBytes = normalizeManifestPositiveInteger( - rawIntegration.maxOutputBytes, - MAX_SECRET_PROVIDER_EXEC_OUTPUT_BYTES, - ); - const env = normalizeStringRecord(rawIntegration.env); - const passEnv = normalizeManifestTrimmedStringArray(rawIntegration.passEnv, { - maxItems: MAX_SECRET_PROVIDER_EXEC_PASS_ENV, - pattern: ENV_SECRET_REF_ID_RE, - }); - normalized[id] = { - ...(providerAlias ? { providerAlias } : {}), - ...(displayName ? { displayName } : {}), - ...(description ? { description } : {}), - source: "exec", - command, - ...(args ? { args } : {}), - ...(timeoutMs !== undefined ? { timeoutMs } : {}), - ...(noOutputTimeoutMs !== undefined ? { noOutputTimeoutMs } : {}), - ...(maxOutputBytes !== undefined ? { maxOutputBytes } : {}), - ...(typeof rawIntegration.jsonOnly === "boolean" - ? { jsonOnly: rawIntegration.jsonOnly } - : {}), - ...(env ? { env } : {}), - ...(passEnv ? { passEnv } : {}), - ...(rawIntegration.allowInsecurePath === true ? { allowInsecurePath: true } : {}), - }; - } - return Object.keys(normalized).length > 0 ? normalized : undefined; -} - -export function normalizeManifestActivation(value: unknown): PluginManifestActivation | undefined { - if (!isRecord(value)) { - return undefined; - } - - const onProviders = normalizeTrimmedStringList(value.onProviders); - const onAgentHarnesses = normalizeTrimmedStringList(value.onAgentHarnesses); - const onCommands = normalizeTrimmedStringList(value.onCommands); - const onChannels = normalizeTrimmedStringList(value.onChannels); - const onRoutes = normalizeTrimmedStringList(value.onRoutes); - const onConfigPaths = normalizeTrimmedStringList(value.onConfigPaths); - const onStartup = typeof value.onStartup === "boolean" ? value.onStartup : undefined; - const onCapabilities = normalizeTrimmedStringList(value.onCapabilities).filter( - (capability): capability is PluginManifestActivationCapability => - capability === "provider" || - capability === "channel" || - capability === "tool" || - capability === "hook", - ); - - const activation = { - ...(onStartup !== undefined ? { onStartup } : {}), - ...(onProviders.length > 0 ? { onProviders } : {}), - ...(onAgentHarnesses.length > 0 ? { onAgentHarnesses } : {}), - ...(onCommands.length > 0 ? { onCommands } : {}), - ...(onChannels.length > 0 ? { onChannels } : {}), - ...(onRoutes.length > 0 ? { onRoutes } : {}), - ...(onConfigPaths.length > 0 ? { onConfigPaths } : {}), - ...(onCapabilities.length > 0 ? { onCapabilities } : {}), - } satisfies PluginManifestActivation; - - return Object.keys(activation).length > 0 ? activation : undefined; -} - -const MANIFEST_DEFAULT_ENABLEMENT_PLATFORMS = new Set([ - "aix", - "android", - "darwin", - "freebsd", - "haiku", - "linux", - "openbsd", - "sunos", - "win32", - "cygwin", - "netbsd", -]); - -function normalizeManifestDefaultPlatforms(value: unknown): PluginManifestDefaultPlatform[] { - return normalizeTrimmedStringList(value).filter( - (platform): platform is PluginManifestDefaultPlatform => - MANIFEST_DEFAULT_ENABLEMENT_PLATFORMS.has(platform as PluginManifestDefaultPlatform), - ); -} - -function normalizeManifestSetupProviders( - value: unknown, -): PluginManifestSetupProvider[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - const normalized: PluginManifestSetupProvider[] = []; - for (const entry of value) { - if (!isRecord(entry)) { - continue; - } - const id = normalizeOptionalString(entry.id) ?? ""; - if (!id) { - continue; - } - const authMethods = normalizeTrimmedStringList(entry.authMethods); - const envVars = normalizeTrimmedStringList(entry.envVars); - const authEvidence = normalizeManifestSetupProviderAuthEvidence(entry.authEvidence); - normalized.push({ - id, - ...(authMethods.length > 0 ? { authMethods } : {}), - ...(envVars.length > 0 ? { envVars } : {}), - ...(authEvidence ? { authEvidence } : {}), - }); - } - return normalized.length > 0 ? normalized : undefined; -} - -function normalizeManifestSetupProviderAuthEvidence( - value: unknown, -): PluginManifestSetupProviderAuthEvidence[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - const normalized: PluginManifestSetupProviderAuthEvidence[] = []; - for (const entry of value) { - if (!isRecord(entry) || entry.type !== "local-file-with-env") { - continue; - } - const credentialMarker = normalizeOptionalString(entry.credentialMarker); - if (!credentialMarker) { - continue; - } - const fileEnvVar = normalizeOptionalString(entry.fileEnvVar); - const fallbackPaths = normalizeTrimmedStringList(entry.fallbackPaths); - if (!fileEnvVar && fallbackPaths.length === 0) { - continue; - } - const requiresAnyEnv = normalizeTrimmedStringList(entry.requiresAnyEnv); - const requiresAllEnv = normalizeTrimmedStringList(entry.requiresAllEnv); - const source = normalizeOptionalString(entry.source); - normalized.push({ - type: "local-file-with-env", - ...(fileEnvVar ? { fileEnvVar } : {}), - ...(fallbackPaths.length > 0 ? { fallbackPaths } : {}), - ...(requiresAnyEnv.length > 0 ? { requiresAnyEnv } : {}), - ...(requiresAllEnv.length > 0 ? { requiresAllEnv } : {}), - credentialMarker, - ...(source ? { source } : {}), - }); - } - return normalized.length > 0 ? normalized : undefined; -} - -function normalizeManifestSetup(value: unknown): PluginManifestSetup | undefined { - if (!isRecord(value)) { - return undefined; - } - const providers = normalizeManifestSetupProviders(value.providers); - const cliBackends = normalizeTrimmedStringList(value.cliBackends); - const configMigrations = normalizeTrimmedStringList(value.configMigrations); - const requiresRuntime = - typeof value.requiresRuntime === "boolean" ? value.requiresRuntime : undefined; - const setup = { - ...(providers ? { providers } : {}), - ...(cliBackends.length > 0 ? { cliBackends } : {}), - ...(configMigrations.length > 0 ? { configMigrations } : {}), - ...(requiresRuntime !== undefined ? { requiresRuntime } : {}), - } satisfies PluginManifestSetup; - return Object.keys(setup).length > 0 ? setup : undefined; -} - -function normalizeManifestQaRunners(value: unknown): PluginManifestQaRunner[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - const normalized: PluginManifestQaRunner[] = []; - for (const entry of value) { - if (!isRecord(entry)) { - continue; - } - const commandName = normalizeOptionalString(entry.commandName) ?? ""; - if (!commandName) { - continue; - } - const description = normalizeOptionalString(entry.description) ?? ""; - normalized.push({ - commandName, - ...(description ? { description } : {}), - }); - } - return normalized.length > 0 ? normalized : undefined; -} - -type DashboardManifestResult = - | { ok: true; dashboard?: PluginManifestDashboard } - | { ok: false; error: string }; - -function normalizeDashboardCapabilityBase( - value: unknown, - field: string, - index: number, -): { id: string; method: string; description: string } | string { - if (!isRecord(value)) { - return `${field}[${index}] must be an object`; - } - const id = normalizeOptionalString(value.id); - const method = normalizeOptionalString(value.method); - const description = normalizeOptionalString(value.description); - if (!id || !/^[a-z0-9][a-z0-9._-]*$/u.test(id)) { - return `${field}[${index}].id must be a lowercase capability id`; - } - if (!method) { - return `${field}[${index}].method must be a non-empty string`; - } - if (!description) { - return `${field}[${index}].description must be a non-empty string`; - } - return { id, method, description }; -} - -function normalizeManifestDashboard(value: unknown): DashboardManifestResult { - if (value === undefined) { - return { ok: true }; - } - if (!isRecord(value)) { - return { ok: false, error: "dashboard must be an object" }; - } - if (value.dataBindings !== undefined && !Array.isArray(value.dataBindings)) { - return { ok: false, error: "dashboard.dataBindings must be an array" }; - } - if (value.actionVerbs !== undefined && !Array.isArray(value.actionVerbs)) { - return { ok: false, error: "dashboard.actionVerbs must be an array" }; - } - - const dataBindings: PluginManifestDashboardDataBinding[] = []; - for (const [index, entry] of (value.dataBindings ?? []).entries()) { - const normalized = normalizeDashboardCapabilityBase(entry, "dashboard.dataBindings", index); - if (typeof normalized === "string") { - return { ok: false, error: normalized }; - } - dataBindings.push(normalized); - } - - const actionVerbs: PluginManifestDashboardActionVerb[] = []; - for (const [index, entry] of (value.actionVerbs ?? []).entries()) { - const normalized = normalizeDashboardCapabilityBase(entry, "dashboard.actionVerbs", index); - if (typeof normalized === "string") { - return { ok: false, error: normalized }; - } - const rawParamShape = isRecord(entry) ? entry.paramShape : undefined; - if (rawParamShape !== undefined && !isRecord(rawParamShape)) { - return { - ok: false, - error: `dashboard.actionVerbs[${index}].paramShape must be a JSON Schema object`, - }; - } - actionVerbs.push({ - ...normalized, - ...(rawParamShape ? { paramShape: rawParamShape as JsonSchemaObject } : {}), - }); - } - - if (dataBindings.length === 0 && actionVerbs.length === 0) { - return { ok: true }; - } - return { - ok: true, - dashboard: { - ...(dataBindings.length > 0 ? { dataBindings } : {}), - ...(actionVerbs.length > 0 ? { actionVerbs } : {}), - }, - }; -} - -function normalizeManifestHttpsUrl(value: unknown): string | undefined { - const normalized = normalizeOptionalString(value); - if (!normalized) { - return undefined; - } - try { - const url = new URL(normalized); - const canonical = url.toString(); - return url.protocol === "https:" && - url.hostname && - !url.username && - !url.password && - canonical.length <= 2048 - ? canonical - : undefined; - } catch { - return undefined; - } -} - -function normalizeProviderAuthChoices( - value: unknown, -): PluginManifestProviderAuthChoice[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - const normalized: PluginManifestProviderAuthChoice[] = []; - for (const entry of value) { - if (!isRecord(entry)) { - continue; - } - const provider = normalizeOptionalString(entry.provider) ?? ""; - const method = normalizeOptionalString(entry.method) ?? ""; - const choiceId = normalizeOptionalString(entry.choiceId) ?? ""; - if (!provider || !method || !choiceId) { - continue; - } - const choiceLabel = normalizeOptionalString(entry.choiceLabel) ?? ""; - const choiceHint = normalizeOptionalString(entry.choiceHint) ?? ""; - const icon = normalizeManifestHttpsUrl(entry.icon); - const website = normalizeManifestHttpsUrl(entry.website); - const assistantPriority = - typeof entry.assistantPriority === "number" && Number.isFinite(entry.assistantPriority) - ? entry.assistantPriority - : undefined; - const assistantVisibility = - entry.assistantVisibility === "manual-only" || entry.assistantVisibility === "visible" - ? entry.assistantVisibility - : undefined; - const deprecatedChoiceIds = normalizeTrimmedStringList(entry.deprecatedChoiceIds); - const groupId = normalizeOptionalString(entry.groupId) ?? ""; - const groupLabel = normalizeOptionalString(entry.groupLabel) ?? ""; - const groupHint = normalizeOptionalString(entry.groupHint) ?? ""; - const onboardingFeatured = entry.onboardingFeatured === true; - const optionKey = normalizeOptionalString(entry.optionKey) ?? ""; - const cliFlag = normalizeOptionalString(entry.cliFlag) ?? ""; - const cliOption = normalizeOptionalString(entry.cliOption) ?? ""; - const cliDescription = normalizeOptionalString(entry.cliDescription) ?? ""; - const appGuidedSecret = entry.appGuidedSecret === true; - const appGuidedAuth = - entry.appGuidedAuth === "oauth" || entry.appGuidedAuth === "device-code" - ? entry.appGuidedAuth - : undefined; - const onboardingScopes = normalizeTrimmedStringList(entry.onboardingScopes).filter( - (scope): scope is PluginManifestOnboardingScope => - scope === "text-inference" || scope === "image-generation" || scope === "music-generation", - ); - const appGuidedDiscovery = entry.appGuidedDiscovery === true; - normalized.push({ - provider, - method, - choiceId, - ...(choiceLabel ? { choiceLabel } : {}), - ...(choiceHint ? { choiceHint } : {}), - ...(icon ? { icon } : {}), - ...(website ? { website } : {}), - ...(assistantPriority !== undefined ? { assistantPriority } : {}), - ...(assistantVisibility ? { assistantVisibility } : {}), - ...(deprecatedChoiceIds.length > 0 ? { deprecatedChoiceIds } : {}), - ...(groupId ? { groupId } : {}), - ...(groupLabel ? { groupLabel } : {}), - ...(groupHint ? { groupHint } : {}), - ...(onboardingFeatured ? { onboardingFeatured: true } : {}), - ...(appGuidedDiscovery ? { appGuidedDiscovery: true } : {}), - ...(optionKey ? { optionKey } : {}), - ...(cliFlag ? { cliFlag } : {}), - ...(cliOption ? { cliOption } : {}), - ...(cliDescription ? { cliDescription } : {}), - ...(appGuidedSecret ? { appGuidedSecret: true } : {}), - ...(appGuidedAuth ? { appGuidedAuth } : {}), - ...(onboardingScopes.length > 0 ? { onboardingScopes } : {}), - }); - } - return normalized.length > 0 ? normalized : undefined; -} - -function normalizeConfigUiHints(value: unknown): Record | undefined { - if (!isRecord(value)) { - return undefined; - } - const normalized: Record = Object.create(null); - for (const [hintPath, rawHint] of Object.entries(value)) { - if (!isRecord(rawHint)) { - continue; - } - const hint = { ...rawHint } as Record; - if ("presentation" in hint && hint.presentation !== "phone-number") { - delete hint.presentation; - } - normalized[hintPath] = hint as PluginConfigUiHint; - } - return Object.keys(normalized).length > 0 ? normalized : undefined; -} - -function normalizeChannelConfigs( - value: unknown, -): Record | undefined { - if (!isRecord(value)) { - return undefined; - } - const normalized: Record = Object.create(null); - for (const [key, rawEntry] of Object.entries(value)) { - const channelId = normalizeOptionalString(key) ?? ""; - if (!channelId || isBlockedObjectKey(channelId) || !isRecord(rawEntry)) { - continue; - } - const schema = isRecord(rawEntry.schema) ? rawEntry.schema : null; - if (!schema) { - continue; - } - const uiHints = normalizeConfigUiHints(rawEntry.uiHints); - const runtime = - isRecord(rawEntry.runtime) && typeof rawEntry.runtime.safeParse === "function" - ? (rawEntry.runtime as ChannelConfigRuntimeSchema) - : undefined; - const label = normalizeOptionalString(rawEntry.label) ?? ""; - const description = normalizeOptionalString(rawEntry.description) ?? ""; - const preferOver = normalizeTrimmedStringList(rawEntry.preferOver); - const commandDefaults = normalizeManifestChannelCommandDefaults(rawEntry.commands); - normalized[channelId] = { - schema, - ...(uiHints ? { uiHints } : {}), - ...(runtime ? { runtime } : {}), - ...(label ? { label } : {}), - ...(description ? { description } : {}), - ...(preferOver.length > 0 ? { preferOver } : {}), - ...(commandDefaults ? { commands: commandDefaults } : {}), - }; - } - return Object.keys(normalized).length > 0 ? normalized : undefined; -} - -export function normalizeManifestChannelCommandDefaults( - value: unknown, -): PluginManifestChannelCommandDefaults | undefined { - if (!isRecord(value)) { - return undefined; - } - const nativeCommandsAutoEnabled = - typeof value.nativeCommandsAutoEnabled === "boolean" - ? value.nativeCommandsAutoEnabled - : undefined; - const nativeSkillsAutoEnabled = - typeof value.nativeSkillsAutoEnabled === "boolean" ? value.nativeSkillsAutoEnabled : undefined; - return nativeCommandsAutoEnabled !== undefined || nativeSkillsAutoEnabled !== undefined - ? { - ...(nativeCommandsAutoEnabled !== undefined ? { nativeCommandsAutoEnabled } : {}), - ...(nativeSkillsAutoEnabled !== undefined ? { nativeSkillsAutoEnabled } : {}), - } - : undefined; -} function resolvePluginManifestPath(rootDir: string): string { for (const filename of PLUGIN_MANIFEST_FILENAMES) { @@ -1971,51 +194,63 @@ export function loadPluginManifest( } const requiresPlugins = normalizeTrimmedStringList(raw.requiresPlugins); - const kind = parsePluginKind(raw.kind); - const enabledByDefault = raw.enabledByDefault === true; - const enabledByDefaultOnPlatforms = normalizeManifestDefaultPlatforms( + const enabledByDefaultOnPlatforms = setupNormalizers.normalizeManifestDefaultPlatforms( raw.enabledByDefaultOnPlatforms, ); const legacyPluginIds = normalizeTrimmedStringList(raw.legacyPluginIds); const autoEnableWhenConfiguredProviders = normalizeTrimmedStringList( raw.autoEnableWhenConfiguredProviders, ); - const name = normalizeOptionalString(raw.name); - const description = normalizeOptionalString(raw.description); - const catalog = normalizeManifestCatalog(raw.catalog); - const icon = normalizeOptionalString(raw.icon); - const version = normalizeOptionalString(raw.version); - const channels = normalizeTrimmedStringList(raw.channels); const providers = normalizeTrimmedStringList(raw.providers); const cliBackends = normalizeTrimmedStringList(raw.cliBackends); - const providerCatalogEntry = normalizeOptionalString(raw.providerCatalogEntry); - const modelSupport = normalizeManifestModelSupport(raw.modelSupport); - const modelCatalog = normalizeModelCatalog(raw.modelCatalog, { - ownedProviders: new Set([...providers, ...cliBackends]), - }); - const modelPricing = normalizeManifestModelPricing(raw.modelPricing, { - ownedProviders: new Set(providers), - }); - const modelIdNormalization = normalizeManifestModelIdNormalization(raw.modelIdNormalization, { - ownedProviders: new Set(providers), - }); - const providerEndpoints = normalizeManifestProviderEndpoints(raw.providerEndpoints); - const providerRequest = normalizeManifestProviderRequest(raw.providerRequest, { - ownedProviders: new Set(providers), - }); - const secretProviderIntegrations = normalizeManifestSecretProviderIntegrations( - raw.secretProviderIntegrations, - ); - const syntheticAuthRefs = normalizeTrimmedStringList(raw.syntheticAuthRefs); - const nonSecretAuthMarkers = normalizeTrimmedStringList(raw.nonSecretAuthMarkers); - const commandAliases = normalizeManifestCommandAliases(raw.commandAliases); - const providerUsageAuthEnvVars = normalizeStringListRecord(raw.providerUsageAuthEnvVars); - const providerAuthAliases = normalizeStringRecord(raw.providerAuthAliases); - const providerAuthChoices = normalizeProviderAuthChoices(raw.providerAuthChoices); - const activation = normalizeManifestActivation(raw.activation); - const setup = normalizeManifestSetup(raw.setup); - const qaRunners = normalizeManifestQaRunners(raw.qaRunners); - const dashboardResult = normalizeManifestDashboard(raw.dashboard); + const manifestBeforeDashboard = { + id, + configSchema, + ...(requiresPlugins.length > 0 ? { requiresPlugins } : {}), + ...(raw.enabledByDefault === true ? { enabledByDefault: true } : {}), + ...(enabledByDefaultOnPlatforms.length > 0 ? { enabledByDefaultOnPlatforms } : {}), + ...(legacyPluginIds.length > 0 ? { legacyPluginIds } : {}), + ...(autoEnableWhenConfiguredProviders.length > 0 ? { autoEnableWhenConfiguredProviders } : {}), + kind: parsePluginKind(raw.kind), + channels: normalizeTrimmedStringList(raw.channels), + providers, + providerCatalogEntry: normalizeOptionalString(raw.providerCatalogEntry), + modelSupport: modelProviderNormalizers.normalizeManifestModelSupport(raw.modelSupport), + modelCatalog: normalizeModelCatalog(raw.modelCatalog, { + ownedProviders: new Set([...providers, ...cliBackends]), + }), + modelPricing: modelProviderNormalizers.normalizeManifestModelPricing(raw.modelPricing, { + ownedProviders: new Set(providers), + }), + modelIdNormalization: modelProviderNormalizers.normalizeManifestModelIdNormalization( + raw.modelIdNormalization, + { ownedProviders: new Set(providers) }, + ), + providerEndpoints: modelProviderNormalizers.normalizeManifestProviderEndpoints( + raw.providerEndpoints, + ), + providerRequest: modelProviderNormalizers.normalizeManifestProviderRequest( + raw.providerRequest, + { ownedProviders: new Set(providers) }, + ), + secretProviderIntegrations: + modelProviderNormalizers.normalizeManifestSecretProviderIntegrations( + raw.secretProviderIntegrations, + ), + cliBackends, + syntheticAuthRefs: normalizeTrimmedStringList(raw.syntheticAuthRefs), + nonSecretAuthMarkers: normalizeTrimmedStringList(raw.nonSecretAuthMarkers), + commandAliases: normalizeManifestCommandAliases(raw.commandAliases), + providerUsageAuthEnvVars: capabilityNormalizers.normalizeStringListRecord( + raw.providerUsageAuthEnvVars, + ), + providerAuthAliases: capabilityNormalizers.normalizeStringRecord(raw.providerAuthAliases), + providerAuthChoices: setupNormalizers.normalizeProviderAuthChoices(raw.providerAuthChoices), + activation: setupNormalizers.normalizeManifestActivation(raw.activation), + setup: setupNormalizers.normalizeManifestSetup(raw.setup), + qaRunners: setupNormalizers.normalizeManifestQaRunners(raw.qaRunners), + }; + const dashboardResult = setupNormalizers.normalizeManifestDashboard(raw.dashboard); if (!dashboardResult.ok) { return cacheResult({ ok: false, @@ -2023,274 +258,38 @@ export function loadPluginManifest( manifestPath, }); } - const dashboard = dashboardResult.dashboard; - const mcpServers = normalizeManifestMcpServers(raw.mcpServers); - const skills = normalizeTrimmedStringList(raw.skills); - const contracts = normalizeManifestContracts(raw.contracts); - const mediaUnderstandingProviderMetadata = normalizeMediaUnderstandingProviderMetadata( - raw.mediaUnderstandingProviderMetadata, - ); - const imageGenerationProviderMetadata = normalizeCapabilityProviderMetadata( - raw.imageGenerationProviderMetadata, - ); - const videoGenerationProviderMetadata = normalizeCapabilityProviderMetadata( - raw.videoGenerationProviderMetadata, - ); - const musicGenerationProviderMetadata = normalizeCapabilityProviderMetadata( - raw.musicGenerationProviderMetadata, - ); - const toolMetadata = normalizePluginToolMetadata(raw.toolMetadata); - const configContracts = normalizeManifestConfigContracts(raw.configContracts); - const channelConfigs = normalizeChannelConfigs(raw.channelConfigs); - - const uiHints = normalizeConfigUiHints(raw.uiHints); return cacheResult({ ok: true, manifest: { - id, - configSchema, - ...(requiresPlugins.length > 0 ? { requiresPlugins } : {}), - ...(enabledByDefault ? { enabledByDefault } : {}), - ...(enabledByDefaultOnPlatforms.length > 0 ? { enabledByDefaultOnPlatforms } : {}), - ...(legacyPluginIds.length > 0 ? { legacyPluginIds } : {}), - ...(autoEnableWhenConfiguredProviders.length > 0 - ? { autoEnableWhenConfiguredProviders } - : {}), - kind, - channels, - providers, - providerCatalogEntry, - modelSupport, - modelCatalog, - modelPricing, - modelIdNormalization, - providerEndpoints, - providerRequest, - secretProviderIntegrations, - cliBackends, - syntheticAuthRefs, - nonSecretAuthMarkers, - commandAliases, - providerUsageAuthEnvVars, - providerAuthAliases, - providerAuthChoices, - activation, - setup, - qaRunners, - dashboard, - mcpServers, - skills, - name, - description, - catalog, - icon, - version, - uiHints, - contracts, - mediaUnderstandingProviderMetadata, - imageGenerationProviderMetadata, - videoGenerationProviderMetadata, - musicGenerationProviderMetadata, - toolMetadata, - configContracts, - channelConfigs, + ...manifestBeforeDashboard, + dashboard: dashboardResult.dashboard, + mcpServers: capabilityNormalizers.normalizeManifestMcpServers(raw.mcpServers), + skills: normalizeTrimmedStringList(raw.skills), + name: normalizeOptionalString(raw.name), + description: normalizeOptionalString(raw.description), + catalog: capabilityNormalizers.normalizeManifestCatalog(raw.catalog), + icon: normalizeOptionalString(raw.icon), + version: normalizeOptionalString(raw.version), + uiHints: setupNormalizers.normalizeConfigUiHints(raw.uiHints), + contracts: capabilityNormalizers.normalizeManifestContracts(raw.contracts), + mediaUnderstandingProviderMetadata: + capabilityNormalizers.normalizeMediaUnderstandingProviderMetadata( + raw.mediaUnderstandingProviderMetadata, + ), + imageGenerationProviderMetadata: capabilityNormalizers.normalizeCapabilityProviderMetadata( + raw.imageGenerationProviderMetadata, + ), + videoGenerationProviderMetadata: capabilityNormalizers.normalizeCapabilityProviderMetadata( + raw.videoGenerationProviderMetadata, + ), + musicGenerationProviderMetadata: capabilityNormalizers.normalizeCapabilityProviderMetadata( + raw.musicGenerationProviderMetadata, + ), + toolMetadata: capabilityNormalizers.normalizePluginToolMetadata(raw.toolMetadata), + configContracts: capabilityNormalizers.normalizeManifestConfigContracts(raw.configContracts), + channelConfigs: setupNormalizers.normalizeChannelConfigs(raw.channelConfigs), }, manifestPath, }); } - -// package.json "openclaw" metadata (used for setup/catalog) -import type { ChannelSetupMetadata } from "../channels/plugins/setup-contract.js"; - -type PluginPackageChannelApprovalFlag = "native"; - -export type PluginPackageChannel = { - id?: string; - label?: string; - selectionLabel?: string; - detailLabel?: string; - docsPath?: string; - docsLabel?: string; - blurb?: string; - order?: number; - aliases?: readonly string[]; - preferOver?: readonly string[]; - systemImage?: string; - selectionDocsPrefix?: string; - selectionDocsOmitLabel?: boolean; - selectionExtras?: readonly string[]; - markdownCapable?: boolean; - /** Closed manifest flags for approval behavior available before the channel runtime loads. */ - approvalFlags?: readonly PluginPackageChannelApprovalFlag[]; - exposure?: { - configured?: boolean; - setup?: boolean; - docs?: boolean; - }; - quickstartAllowFrom?: boolean; - forceAccountBinding?: boolean; - preferSessionLookupForAnnounceTarget?: boolean; - commands?: PluginManifestChannelCommandDefaults; - configuredState?: { - specifier?: string; - exportName?: string; - env?: { - allOf?: readonly string[]; - anyOf?: readonly string[]; - }; - }; - persistedAuthState?: { - specifier?: string; - exportName?: string; - }; - doctorCapabilities?: PluginPackageChannelDoctorCapabilities; - /** Typed, serializable setup fields available before plugin runtime load. */ - setup?: ChannelSetupMetadata; - /** @deprecated Use setup.fields. */ - cliAddOptions?: readonly PluginPackageChannelCliOption[]; -}; - -export type PluginPackageChannelDoctorCapabilities = { - dmAllowFromMode?: "topOnly" | "topOrNested" | "nestedOnly"; - groupModel?: "sender" | "route" | "hybrid"; - groupAllowFromFallbackToAllowFrom?: boolean; - warnOnEmptyGroupSenderAllowlist?: boolean; -}; - -export type PluginPackageChannelCliOption = { - flags: string; - negatedFlags?: string; - description: string; - defaultValue?: boolean | string; - valueType?: "int" | "list"; -}; - -export type PluginPackageInstall = { - clawhubSpec?: string; - npmSpec?: string; - localPath?: string; - defaultChoice?: "clawhub" | "npm" | "local"; - minHostVersion?: string; - expectedIntegrity?: string; - allowInvalidConfigRecovery?: boolean; - requiredPlatformPackages?: string[]; -}; - -type OpenClawPackageStartup = { - /** - * Opt-in for channel plugins whose `setupEntry` fully covers the gateway - * startup surface needed before the server starts listening. - */ - deferConfiguredChannelFullLoadUntilAfterListen?: boolean; -}; - -type OpenClawPackageSetupFeatures = { - configPromotion?: boolean; - legacyStateMigrations?: boolean; - legacySessionSurfaces?: boolean; -}; - -type OpenClawPackageCompat = { - pluginApi?: string; - minGatewayVersion?: string; -}; - -export type OpenClawPackageBuild = { - bundledDist?: boolean; - openclawVersion?: string; - pluginSdkVersion?: string; -}; - -export type OpenClawPackageManifest = { - extensions?: string[]; - runtimeExtensions?: string[]; - setupEntry?: string; - runtimeSetupEntry?: string; - setupFeatures?: OpenClawPackageSetupFeatures; - plugin?: { - id?: string; - label?: string; - }; - channel?: PluginPackageChannel; - compat?: OpenClawPackageCompat; - install?: PluginPackageInstall; - startup?: OpenClawPackageStartup; - build?: OpenClawPackageBuild; -}; - -export const DEFAULT_PLUGIN_ENTRY_CANDIDATES = [ - "index.ts", - "index.js", - "index.mjs", - "index.cjs", -] as const; - -export type PackageExtensionResolution = - | { status: "ok"; entries: string[] } - | { status: "missing"; entries: [] } - | { status: "empty"; entries: [] } - | { status: "invalid"; entries: []; error: string }; - -type ManifestKey = typeof MANIFEST_KEY; - -export type PackageManifest = { - name?: string; - version?: string; - description?: string; - dependencies?: Record; - optionalDependencies?: Record; -} & Partial>; - -export function getPackageManifestMetadata( - manifest: PackageManifest | undefined, -): OpenClawPackageManifest | undefined { - if (!manifest) { - return undefined; - } - return manifest[MANIFEST_KEY]; -} - -export function resolvePackageExtensionEntries( - manifest: PackageManifest | undefined, -): PackageExtensionResolution { - const rawOpenClaw = manifest?.[MANIFEST_KEY] as unknown; - if (rawOpenClaw === undefined || rawOpenClaw === null) { - return { status: "missing", entries: [] }; - } - if (!isRecord(rawOpenClaw)) { - return { - status: "invalid", - entries: [], - error: "package.json openclaw must be an object", - }; - } - const raw = rawOpenClaw.extensions; - if (raw === undefined || raw === null) { - return { status: "missing", entries: [] }; - } - if (!Array.isArray(raw)) { - return { - status: "invalid", - entries: [], - error: "package.json openclaw.extensions must be an array", - }; - } - const entries: string[] = []; - for (const [index, entry] of raw.entries()) { - const normalized = normalizeOptionalString(entry); - if (!normalized) { - return { - status: "invalid", - entries: [], - error: `package.json openclaw.extensions[${index}] must be a non-empty string`, - }; - } - entries.push(normalized); - } - if (entries.length === 0) { - return { status: "empty", entries: [] }; - } - return { status: "ok", entries }; -} -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/plugins/package-manifest.ts b/src/plugins/package-manifest.ts new file mode 100644 index 000000000000..5ed84580e1d2 --- /dev/null +++ b/src/plugins/package-manifest.ts @@ -0,0 +1,197 @@ +import { normalizeOptionalString } from "../../packages/normalization-core/src/string-coerce.js"; +import type { ChannelSetupMetadata } from "../channels/plugins/setup-contract.js"; +import { MANIFEST_KEY } from "../compat/legacy-names.js"; +import { isRecord } from "../utils.js"; +import type { PluginManifestChannelCommandDefaults } from "./manifest-types.js"; + +/** package.json OpenClaw metadata used for plugin setup and catalog discovery. */ +type PluginPackageChannelApprovalFlag = "native"; + +export type PluginPackageChannel = { + id?: string; + label?: string; + selectionLabel?: string; + detailLabel?: string; + docsPath?: string; + docsLabel?: string; + blurb?: string; + order?: number; + aliases?: readonly string[]; + preferOver?: readonly string[]; + systemImage?: string; + selectionDocsPrefix?: string; + selectionDocsOmitLabel?: boolean; + selectionExtras?: readonly string[]; + markdownCapable?: boolean; + /** Closed manifest flags for approval behavior available before the channel runtime loads. */ + approvalFlags?: readonly PluginPackageChannelApprovalFlag[]; + exposure?: { + configured?: boolean; + setup?: boolean; + docs?: boolean; + }; + quickstartAllowFrom?: boolean; + forceAccountBinding?: boolean; + preferSessionLookupForAnnounceTarget?: boolean; + commands?: PluginManifestChannelCommandDefaults; + configuredState?: { + specifier?: string; + exportName?: string; + env?: { + allOf?: readonly string[]; + anyOf?: readonly string[]; + }; + }; + persistedAuthState?: { + specifier?: string; + exportName?: string; + }; + doctorCapabilities?: PluginPackageChannelDoctorCapabilities; + /** Typed, serializable setup fields available before plugin runtime load. */ + setup?: ChannelSetupMetadata; + /** @deprecated Use setup.fields. */ + cliAddOptions?: readonly PluginPackageChannelCliOption[]; +}; + +export type PluginPackageChannelDoctorCapabilities = { + dmAllowFromMode?: "topOnly" | "topOrNested" | "nestedOnly"; + groupModel?: "sender" | "route" | "hybrid"; + groupAllowFromFallbackToAllowFrom?: boolean; + warnOnEmptyGroupSenderAllowlist?: boolean; +}; + +export type PluginPackageChannelCliOption = { + flags: string; + negatedFlags?: string; + description: string; + defaultValue?: boolean | string; + valueType?: "int" | "list"; +}; + +export type PluginPackageInstall = { + clawhubSpec?: string; + npmSpec?: string; + localPath?: string; + defaultChoice?: "clawhub" | "npm" | "local"; + minHostVersion?: string; + expectedIntegrity?: string; + allowInvalidConfigRecovery?: boolean; + requiredPlatformPackages?: string[]; +}; + +type OpenClawPackageStartup = { + /** + * Opt-in for channel plugins whose `setupEntry` fully covers the gateway + * startup surface needed before the server starts listening. + */ + deferConfiguredChannelFullLoadUntilAfterListen?: boolean; +}; + +type OpenClawPackageSetupFeatures = { + configPromotion?: boolean; + legacyStateMigrations?: boolean; + legacySessionSurfaces?: boolean; +}; + +type OpenClawPackageCompat = { + pluginApi?: string; + minGatewayVersion?: string; +}; + +export type OpenClawPackageBuild = { + bundledDist?: boolean; + openclawVersion?: string; + pluginSdkVersion?: string; +}; + +export type OpenClawPackageManifest = { + extensions?: string[]; + runtimeExtensions?: string[]; + setupEntry?: string; + runtimeSetupEntry?: string; + setupFeatures?: OpenClawPackageSetupFeatures; + plugin?: { + id?: string; + label?: string; + }; + channel?: PluginPackageChannel; + compat?: OpenClawPackageCompat; + install?: PluginPackageInstall; + startup?: OpenClawPackageStartup; + build?: OpenClawPackageBuild; +}; + +export const DEFAULT_PLUGIN_ENTRY_CANDIDATES = [ + "index.ts", + "index.js", + "index.mjs", + "index.cjs", +] as const; + +export type PackageExtensionResolution = + | { status: "ok"; entries: string[] } + | { status: "missing"; entries: [] } + | { status: "empty"; entries: [] } + | { status: "invalid"; entries: []; error: string }; + +type ManifestKey = typeof MANIFEST_KEY; + +export type PackageManifest = { + name?: string; + version?: string; + description?: string; + dependencies?: Record; + optionalDependencies?: Record; +} & Partial>; + +export function getPackageManifestMetadata( + manifest: PackageManifest | undefined, +): OpenClawPackageManifest | undefined { + if (!manifest) { + return undefined; + } + return manifest[MANIFEST_KEY]; +} + +export function resolvePackageExtensionEntries( + manifest: PackageManifest | undefined, +): PackageExtensionResolution { + const rawOpenClaw = manifest?.[MANIFEST_KEY] as unknown; + if (rawOpenClaw === undefined || rawOpenClaw === null) { + return { status: "missing", entries: [] }; + } + if (!isRecord(rawOpenClaw)) { + return { + status: "invalid", + entries: [], + error: "package.json openclaw must be an object", + }; + } + const raw = rawOpenClaw.extensions; + if (raw === undefined || raw === null) { + return { status: "missing", entries: [] }; + } + if (!Array.isArray(raw)) { + return { + status: "invalid", + entries: [], + error: "package.json openclaw.extensions must be an array", + }; + } + const entries: string[] = []; + for (const [index, entry] of raw.entries()) { + const normalized = normalizeOptionalString(entry); + if (!normalized) { + return { + status: "invalid", + entries: [], + error: `package.json openclaw.extensions[${index}] must be a non-empty string`, + }; + } + entries.push(normalized); + } + if (entries.length === 0) { + return { status: "empty", entries: [] }; + } + return { status: "ok", entries }; +}