mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(plugins): split manifest normalization (#113720)
This commit is contained in:
committed by
GitHub
parent
4032ae5247
commit
589f28b57a
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user