refactor(plugins): split manifest normalization (#113720)

This commit is contained in:
Peter Steinberger
2026-07-25 08:27:03 -07:00
committed by GitHub
parent 4032ae5247
commit 589f28b57a
7 changed files with 2091 additions and 2094 deletions
-1
View File
@@ -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
@@ -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<string, string[]> | undefined {
if (!isRecord(value)) {
return undefined;
}
const normalized: Record<string, string[]> = 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<string, string> | undefined {
if (!isRecord(value)) {
return undefined;
}
const normalized: Record<string, string> = 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<string, PluginManifestMcpServer> | undefined {
if (!isRecord(value)) {
return undefined;
}
const normalized: Record<string, PluginManifestMcpServer> = 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<T>(
value: unknown,
normalizeEntry: (entry: Record<string, unknown>) => T | undefined,
): Record<string, T> | undefined {
if (!isRecord(value)) {
return undefined;
}
const normalized: Record<string, T> = 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<Record<PluginManifestMediaUnderstandingCapability, string>> | undefined {
if (!isRecord(value)) {
return undefined;
}
const normalized: Partial<Record<PluginManifestMediaUnderstandingCapability, string>> = {};
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<Record<PluginManifestMediaUnderstandingCapability, number>> | undefined {
if (!isRecord(value)) {
return undefined;
}
const normalized: Partial<Record<PluginManifestMediaUnderstandingCapability, number>> = {};
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<string, PluginManifestMediaUnderstandingProviderMetadata> | 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<string, unknown>,
): 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<string, PluginManifestCapabilityProviderMetadata> | undefined {
return normalizeNamedMetadataRecord(value, normalizeCapabilityProviderMetadataEntry);
}
export function normalizePluginToolMetadata(
value: unknown,
): Record<string, PluginManifestToolMetadata> | 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;
}
@@ -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<T>(
value: unknown,
ownedProvidersRaw: ReadonlySet<string>,
normalizePolicy: (value: unknown) => T | undefined,
): Record<string, T> | undefined {
if (!isRecord(value) || !isRecord(value.providers)) {
return undefined;
}
const ownedProviders = new Set(
[...ownedProvidersRaw]
.map((provider) => normalizeModelCatalogProviderId(provider))
.filter(Boolean),
);
const providers: Record<string, T> = {};
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<string> },
): 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<string, string> = {};
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<string> },
): 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<string> },
): 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<string, PluginManifestSecretProviderIntegration> | undefined {
if (!isRecord(value)) {
return undefined;
}
const normalized: Record<string, PluginManifestSecretProviderIntegration> = 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;
}
+437
View File
@@ -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<PluginManifestDefaultPlatform>([
"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<string, PluginConfigUiHint> | undefined {
if (!isRecord(value)) {
return undefined;
}
const normalized: Record<string, PluginConfigUiHint> = Object.create(null);
for (const [hintPath, rawHint] of Object.entries(value)) {
if (!isRecord(rawHint)) {
continue;
}
const hint = { ...rawHint } as Record<string, unknown>;
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<string, PluginManifestChannelConfig> | undefined {
if (!isRecord(value)) {
return undefined;
}
const normalized: Record<string, PluginManifestChannelConfig> = 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;
}
+531
View File
@@ -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<string, PluginConfigUiHint>;
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<string, PluginManifestModelPricingProvider>;
};
export type PluginManifestModelIdPrefixRule = {
modelPrefix: string;
prefix: string;
};
export type PluginManifestModelIdNormalizationProvider = {
aliases?: Record<string, string>;
stripPrefixes?: string[];
prefixWhenBare?: string;
prefixWhenBareAfterAliasStartsWith?: PluginManifestModelIdPrefixRule[];
};
export type PluginManifestModelIdNormalization = {
providers?: Record<string, PluginManifestModelIdNormalizationProvider>;
};
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<string, PluginManifestProviderRequestProvider>;
};
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<string, string>;
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<string, unknown>;
export type PluginManifestConfigLiteral = string | number | boolean | null;
export type PluginManifestDangerousConfigFlag = {
/**
* Dot-separated config path relative to `plugins.entries.<id>.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.<id>.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<string, PluginManifestSecretProviderIntegration>;
/** 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<string, string[]>;
/** Provider ids that should reuse another provider id for auth lookup. */
providerAuthAliases?: Record<string, string>;
/**
* 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<string, PluginManifestMcpServer>;
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<string, PluginConfigUiHint>;
/**
* 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<string, PluginManifestCapabilityProviderMetadata>;
/** Cheap video-generation provider auth metadata without importing plugin runtime. */
videoGenerationProviderMetadata?: Record<string, PluginManifestCapabilityProviderMetadata>;
/** Cheap music-generation provider auth metadata without importing plugin runtime. */
musicGenerationProviderMetadata?: Record<string, PluginManifestCapabilityProviderMetadata>;
/** Cheap plugin-tool availability metadata without importing plugin runtime. */
toolMetadata?: Record<string, PluginManifestToolMetadata>;
/** Manifest-owned config behavior consumed by generic core helpers. */
configContracts?: PluginManifestConfigContracts;
channelConfigs?: Record<string, PluginManifestChannelConfig>;
};
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<Record<PluginManifestMediaUnderstandingCapability, string>>;
autoPriority?: Partial<Record<PluginManifestMediaUnderstandingCapability, number>>;
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";
+92 -2093
View File
File diff suppressed because it is too large Load Diff
+197
View File
@@ -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<string, string>;
optionalDependencies?: Record<string, string>;
} & Partial<Record<ManifestKey, OpenClawPackageManifest>>;
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 };
}